feat: Permettre la définition d'une semaine type récurrente pour l'emploi du temps
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

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:
2026-03-04 20:03:12 +01:00
parent e156755b86
commit ae640e91ac
35 changed files with 3550 additions and 81 deletions

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Domain\Model\Schedule;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Model\Schedule\ScheduleException;
use App\Scolarite\Domain\Model\Schedule\ScheduleExceptionId;
use App\Scolarite\Domain\Model\Schedule\ScheduleExceptionType;
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 ScheduleExceptionTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string SLOT_ID = '550e8400-e29b-41d4-a716-446655440050';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private const string CREATOR_ID = '550e8400-e29b-41d4-a716-446655440099';
#[Test]
public function annulerCreatesACancelledExceptionWithCorrectProperties(): void
{
$exception = ScheduleException::annuler(
tenantId: TenantId::fromString(self::TENANT_ID),
slotId: ScheduleSlotId::fromString(self::SLOT_ID),
exceptionDate: new DateTimeImmutable('2026-10-05'),
reason: 'Grève',
createdBy: UserId::fromString(self::CREATOR_ID),
now: new DateTimeImmutable('2026-10-01 10:00:00'),
);
self::assertTrue($exception->tenantId->equals(TenantId::fromString(self::TENANT_ID)));
self::assertTrue($exception->slotId->equals(ScheduleSlotId::fromString(self::SLOT_ID)));
self::assertSame('2026-10-05', $exception->exceptionDate->format('Y-m-d'));
self::assertSame(ScheduleExceptionType::CANCELLED, $exception->type);
self::assertSame('Grève', $exception->reason);
self::assertNull($exception->newTimeSlot);
self::assertNull($exception->newRoom);
self::assertNull($exception->newTeacherId);
self::assertTrue($exception->createdBy->equals(UserId::fromString(self::CREATOR_ID)));
}
#[Test]
public function modifierCreatesAModifiedExceptionWithNewValues(): void
{
$newTimeSlot = new TimeSlot('10:00', '11:00');
$newTeacherId = UserId::fromString(self::TEACHER_ID);
$exception = ScheduleException::modifier(
tenantId: TenantId::fromString(self::TENANT_ID),
slotId: ScheduleSlotId::fromString(self::SLOT_ID),
exceptionDate: new DateTimeImmutable('2026-10-05'),
newTimeSlot: $newTimeSlot,
newRoom: 'Salle 301',
newTeacherId: $newTeacherId,
reason: 'Changement de salle',
createdBy: UserId::fromString(self::CREATOR_ID),
now: new DateTimeImmutable('2026-10-01 10:00:00'),
);
self::assertSame(ScheduleExceptionType::MODIFIED, $exception->type);
self::assertNotNull($exception->newTimeSlot);
self::assertSame('10:00', $exception->newTimeSlot->startTime);
self::assertSame('11:00', $exception->newTimeSlot->endTime);
self::assertSame('Salle 301', $exception->newRoom);
self::assertTrue($exception->newTeacherId->equals($newTeacherId));
}
#[Test]
public function modifierWithPartialOverridesKeepsNullsForUnchangedFields(): void
{
$exception = ScheduleException::modifier(
tenantId: TenantId::fromString(self::TENANT_ID),
slotId: ScheduleSlotId::fromString(self::SLOT_ID),
exceptionDate: new DateTimeImmutable('2026-10-05'),
newTimeSlot: null,
newRoom: 'Salle 302',
newTeacherId: null,
reason: null,
createdBy: UserId::fromString(self::CREATOR_ID),
now: new DateTimeImmutable('2026-10-01 10:00:00'),
);
self::assertSame(ScheduleExceptionType::MODIFIED, $exception->type);
self::assertNull($exception->newTimeSlot);
self::assertSame('Salle 302', $exception->newRoom);
self::assertNull($exception->newTeacherId);
self::assertNull($exception->reason);
}
#[Test]
public function isCancelledReturnsTrueForCancelledType(): void
{
$exception = ScheduleException::annuler(
tenantId: TenantId::fromString(self::TENANT_ID),
slotId: ScheduleSlotId::fromString(self::SLOT_ID),
exceptionDate: new DateTimeImmutable('2026-10-05'),
reason: null,
createdBy: UserId::fromString(self::CREATOR_ID),
now: new DateTimeImmutable('2026-10-01 10:00:00'),
);
self::assertTrue($exception->isCancelled());
self::assertFalse($exception->isModified());
}
#[Test]
public function isModifiedReturnsTrueForModifiedType(): void
{
$exception = ScheduleException::modifier(
tenantId: TenantId::fromString(self::TENANT_ID),
slotId: ScheduleSlotId::fromString(self::SLOT_ID),
exceptionDate: new DateTimeImmutable('2026-10-05'),
newTimeSlot: new TimeSlot('14:00', '15:00'),
newRoom: null,
newTeacherId: null,
reason: null,
createdBy: UserId::fromString(self::CREATOR_ID),
now: new DateTimeImmutable('2026-10-01 10:00:00'),
);
self::assertTrue($exception->isModified());
self::assertFalse($exception->isCancelled());
}
#[Test]
public function reconstituteRestoresAllPropertiesWithoutEvents(): void
{
$id = ScheduleExceptionId::generate();
$tenantId = TenantId::fromString(self::TENANT_ID);
$slotId = ScheduleSlotId::fromString(self::SLOT_ID);
$newTimeSlot = new TimeSlot('11:00', '12:00');
$newTeacherId = UserId::fromString(self::TEACHER_ID);
$createdBy = UserId::fromString(self::CREATOR_ID);
$createdAt = new DateTimeImmutable('2026-10-01 10:00:00');
$exception = ScheduleException::reconstitute(
id: $id,
tenantId: $tenantId,
slotId: $slotId,
exceptionDate: new DateTimeImmutable('2026-10-05'),
type: ScheduleExceptionType::MODIFIED,
newTimeSlot: $newTimeSlot,
newRoom: 'Salle 303',
newTeacherId: $newTeacherId,
reason: 'Interversion',
createdBy: $createdBy,
createdAt: $createdAt,
);
self::assertTrue($exception->id->equals($id));
self::assertTrue($exception->tenantId->equals($tenantId));
self::assertTrue($exception->slotId->equals($slotId));
self::assertSame('2026-10-05', $exception->exceptionDate->format('Y-m-d'));
self::assertSame(ScheduleExceptionType::MODIFIED, $exception->type);
self::assertSame('11:00', $exception->newTimeSlot->startTime);
self::assertSame('Salle 303', $exception->newRoom);
self::assertTrue($exception->newTeacherId->equals($newTeacherId));
self::assertSame('Interversion', $exception->reason);
self::assertTrue($exception->createdBy->equals($createdBy));
self::assertEquals($createdAt, $exception->createdAt);
}
}

View File

@@ -176,6 +176,161 @@ final class ScheduleSlotTest extends TestCase
self::assertEmpty($slot->pullDomainEvents());
}
#[Test]
public function creerWithRecurrenceBoundsSetsDates(): void
{
$recurrenceStart = new DateTimeImmutable('2026-09-01');
$recurrenceEnd = new DateTimeImmutable('2027-07-04');
$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-09-01 10:00:00'),
recurrenceStart: $recurrenceStart,
recurrenceEnd: $recurrenceEnd,
);
self::assertEquals($recurrenceStart, $slot->recurrenceStart);
self::assertEquals($recurrenceEnd, $slot->recurrenceEnd);
}
#[Test]
public function creerWithoutRecurrenceBoundsDefaultsToNull(): void
{
$slot = $this->createSlot();
self::assertNull($slot->recurrenceStart);
self::assertNull($slot->recurrenceEnd);
}
#[Test]
public function isActiveOnDateReturnsTrueForMatchingDayWithinBounds(): void
{
$slot = $this->createRecurringSlot(
dayOfWeek: DayOfWeek::MONDAY,
recurrenceStart: new DateTimeImmutable('2026-09-01'),
recurrenceEnd: new DateTimeImmutable('2027-07-04'),
);
// 2026-09-07 is a Monday within bounds
self::assertTrue($slot->isActiveOnDate(new DateTimeImmutable('2026-09-07')));
}
#[Test]
public function isActiveOnDateReturnsFalseForWrongDayOfWeek(): void
{
$slot = $this->createRecurringSlot(
dayOfWeek: DayOfWeek::MONDAY,
recurrenceStart: new DateTimeImmutable('2026-09-01'),
recurrenceEnd: new DateTimeImmutable('2027-07-04'),
);
// 2026-09-08 is a Tuesday
self::assertFalse($slot->isActiveOnDate(new DateTimeImmutable('2026-09-08')));
}
#[Test]
public function isActiveOnDateReturnsFalseBeforeRecurrenceStart(): void
{
$slot = $this->createRecurringSlot(
dayOfWeek: DayOfWeek::MONDAY,
recurrenceStart: new DateTimeImmutable('2026-09-01'),
recurrenceEnd: new DateTimeImmutable('2027-07-04'),
);
// 2026-08-25 is a Monday but before start
self::assertFalse($slot->isActiveOnDate(new DateTimeImmutable('2026-08-25')));
}
#[Test]
public function isActiveOnDateReturnsFalseAfterRecurrenceEnd(): void
{
$slot = $this->createRecurringSlot(
dayOfWeek: DayOfWeek::MONDAY,
recurrenceStart: new DateTimeImmutable('2026-09-01'),
recurrenceEnd: new DateTimeImmutable('2027-07-04'),
);
// 2027-07-07 is a Monday but after end
self::assertFalse($slot->isActiveOnDate(new DateTimeImmutable('2027-07-07')));
}
#[Test]
public function isActiveOnDateReturnsTrueWithNoBounds(): void
{
$slot = $this->createRecurringSlot(
dayOfWeek: DayOfWeek::MONDAY,
recurrenceStart: null,
recurrenceEnd: null,
);
// Any Monday should work
self::assertTrue($slot->isActiveOnDate(new DateTimeImmutable('2026-09-07')));
}
#[Test]
public function isActiveOnDateReturnsFalseForNonRecurringSlot(): void
{
$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: false,
now: new DateTimeImmutable('2026-09-01 10:00:00'),
);
self::assertFalse($slot->isActiveOnDate(new DateTimeImmutable('2026-09-07')));
}
#[Test]
public function isActiveOnDateIncludesBoundaryDates(): void
{
$slot = $this->createRecurringSlot(
dayOfWeek: DayOfWeek::MONDAY,
recurrenceStart: new DateTimeImmutable('2026-09-07'),
recurrenceEnd: new DateTimeImmutable('2026-09-07'),
);
// Exact start = exact end date
self::assertTrue($slot->isActiveOnDate(new DateTimeImmutable('2026-09-07')));
}
#[Test]
public function reconstituteRestoresRecurrenceBounds(): void
{
$recurrenceStart = new DateTimeImmutable('2026-09-01');
$recurrenceEnd = new DateTimeImmutable('2027-07-04');
$slot = ScheduleSlot::reconstitute(
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),
dayOfWeek: DayOfWeek::FRIDAY,
timeSlot: new TimeSlot('14:00', '15:30'),
room: null,
isRecurring: true,
createdAt: new DateTimeImmutable('2026-03-01 10:00:00'),
updatedAt: new DateTimeImmutable('2026-03-02 14:00:00'),
recurrenceStart: $recurrenceStart,
recurrenceEnd: $recurrenceEnd,
);
self::assertEquals($recurrenceStart, $slot->recurrenceStart);
self::assertEquals($recurrenceEnd, $slot->recurrenceEnd);
}
private function createSlot(?string $room = null): ScheduleSlot
{
return ScheduleSlot::creer(
@@ -190,4 +345,24 @@ final class ScheduleSlotTest extends TestCase
now: new DateTimeImmutable('2026-03-01 10:00:00'),
);
}
private function createRecurringSlot(
DayOfWeek $dayOfWeek,
?DateTimeImmutable $recurrenceStart,
?DateTimeImmutable $recurrenceEnd,
): 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,
timeSlot: new TimeSlot('08:00', '09:00'),
room: null,
isRecurring: true,
now: new DateTimeImmutable('2026-09-01 10:00:00'),
recurrenceStart: $recurrenceStart,
recurrenceEnd: $recurrenceEnd,
);
}
}