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,22 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\CreateScheduleException;
final readonly class CreateScheduleExceptionCommand
{
public function __construct(
public string $tenantId,
public string $slotId,
public string $exceptionDate,
public string $type,
public string $createdBy,
public ?string $newStartTime = null,
public ?string $newEndTime = null,
public ?string $newRoom = null,
public ?string $newTeacherId = null,
public ?string $reason = null,
) {
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\CreateScheduleException;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Exception\DateExceptionInvalideException;
use App\Scolarite\Domain\Model\Schedule\ScheduleException;
use App\Scolarite\Domain\Model\Schedule\ScheduleExceptionType;
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
use App\Scolarite\Domain\Repository\ScheduleExceptionRepository;
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class CreateScheduleExceptionHandler
{
public function __construct(
private ScheduleSlotRepository $slotRepository,
private ScheduleExceptionRepository $exceptionRepository,
private Clock $clock,
) {
}
public function __invoke(CreateScheduleExceptionCommand $command): ScheduleException
{
$tenantId = TenantId::fromString($command->tenantId);
$slotId = ScheduleSlotId::fromString($command->slotId);
$slot = $this->slotRepository->get($slotId, $tenantId);
$type = ScheduleExceptionType::from($command->type);
$exceptionDate = new DateTimeImmutable($command->exceptionDate);
if (!$slot->isActiveOnDate($exceptionDate)) {
throw DateExceptionInvalideException::pourSlotEtDate($slotId, $exceptionDate);
}
$createdBy = UserId::fromString($command->createdBy);
$exception = match ($type) {
ScheduleExceptionType::CANCELLED => ScheduleException::annuler(
tenantId: $tenantId,
slotId: $slotId,
exceptionDate: $exceptionDate,
reason: $command->reason,
createdBy: $createdBy,
now: $this->clock->now(),
),
ScheduleExceptionType::MODIFIED => ScheduleException::modifier(
tenantId: $tenantId,
slotId: $slotId,
exceptionDate: $exceptionDate,
newTimeSlot: $command->newStartTime !== null && $command->newEndTime !== null
? new TimeSlot($command->newStartTime, $command->newEndTime)
: null,
newRoom: $command->newRoom,
newTeacherId: $command->newTeacherId !== null
? UserId::fromString($command->newTeacherId)
: null,
reason: $command->reason,
createdBy: $createdBy,
now: $this->clock->now(),
),
};
$this->exceptionRepository->save($exception);
return $exception;
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\TruncateSlotRecurrence;
final readonly class TruncateSlotRecurrenceCommand
{
public function __construct(
public string $tenantId,
public string $slotId,
public string $fromDate,
public string $updatedBy,
) {
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\TruncateSlotRecurrence;
use App\Scolarite\Domain\Exception\DateExceptionInvalideException;
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 DateTimeImmutable;
final readonly class TruncateSlotRecurrenceHandler
{
public function __construct(
private ScheduleSlotRepository $slotRepository,
private Clock $clock,
) {
}
public function __invoke(TruncateSlotRecurrenceCommand $command): void
{
$tenantId = TenantId::fromString($command->tenantId);
$slotId = ScheduleSlotId::fromString($command->slotId);
$slot = $this->slotRepository->get($slotId, $tenantId);
$fromDate = new DateTimeImmutable($command->fromDate);
if (!$slot->isActiveOnDate($fromDate)) {
throw DateExceptionInvalideException::pourSlotEtDate($slotId, $fromDate);
}
$now = $this->clock->now();
$dayBefore = $fromDate->modify('-1 day');
$slot->terminerRecurrenceLe($dayBefore, $now);
$this->slotRepository->save($slot);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\UpdateRecurringSlot;
final readonly class UpdateRecurringSlotCommand
{
/**
* @param string $scope 'this_occurrence' | 'all_future'
*/
public function __construct(
public string $tenantId,
public string $slotId,
public string $occurrenceDate,
public string $scope,
public string $classId,
public string $subjectId,
public string $teacherId,
public int $dayOfWeek,
public string $startTime,
public string $endTime,
public ?string $room,
public string $updatedBy,
) {
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\UpdateRecurringSlot;
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\DateExceptionInvalideException;
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
use App\Scolarite\Domain\Model\Schedule\ScheduleException;
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\ScheduleExceptionRepository;
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class UpdateRecurringSlotHandler
{
public function __construct(
private ScheduleSlotRepository $slotRepository,
private ScheduleExceptionRepository $exceptionRepository,
private Clock $clock,
) {
}
/**
* @return array{exception: ?ScheduleException, newSlot: ?ScheduleSlot}
*/
public function __invoke(UpdateRecurringSlotCommand $command): array
{
$tenantId = TenantId::fromString($command->tenantId);
$slotId = ScheduleSlotId::fromString($command->slotId);
$slot = $this->slotRepository->get($slotId, $tenantId);
$now = $this->clock->now();
$occurrenceDate = new DateTimeImmutable($command->occurrenceDate);
$newTimeSlot = new TimeSlot($command->startTime, $command->endTime);
if (!$slot->isActiveOnDate($occurrenceDate)) {
throw DateExceptionInvalideException::pourSlotEtDate($slotId, $occurrenceDate);
}
if ($command->scope === 'this_occurrence') {
return $this->handleThisOccurrence(
$tenantId,
$slotId,
$occurrenceDate,
$newTimeSlot,
$command->room,
UserId::fromString($command->teacherId),
UserId::fromString($command->updatedBy),
$now,
);
}
// all_future: end current recurrence, create new slot
return $this->handleAllFuture(
$slot,
$tenantId,
$occurrenceDate,
$command,
$now,
);
}
/**
* @return array{exception: ScheduleException, newSlot: null}
*/
private function handleThisOccurrence(
TenantId $tenantId,
ScheduleSlotId $slotId,
DateTimeImmutable $occurrenceDate,
TimeSlot $newTimeSlot,
?string $newRoom,
UserId $newTeacherId,
UserId $createdBy,
DateTimeImmutable $now,
): array {
$exception = ScheduleException::modifier(
tenantId: $tenantId,
slotId: $slotId,
exceptionDate: $occurrenceDate,
newTimeSlot: $newTimeSlot,
newRoom: $newRoom,
newTeacherId: $newTeacherId,
reason: null,
createdBy: $createdBy,
now: $now,
);
$this->exceptionRepository->save($exception);
return ['exception' => $exception, 'newSlot' => null];
}
/**
* @return array{exception: null, newSlot: ScheduleSlot}
*/
private function handleAllFuture(
ScheduleSlot $slot,
TenantId $tenantId,
DateTimeImmutable $occurrenceDate,
UpdateRecurringSlotCommand $command,
DateTimeImmutable $now,
): array {
// Save original end date before modifying
$originalRecurrenceEnd = $slot->recurrenceEnd;
// End original slot one day before the change
$dayBefore = $occurrenceDate->modify('-1 day');
$slot->terminerRecurrenceLe($dayBefore, $now);
$this->slotRepository->save($slot);
// Create new slot starting from the occurrence date
$newSlot = ScheduleSlot::creer(
tenantId: $tenantId,
classId: ClassId::fromString($command->classId),
subjectId: SubjectId::fromString($command->subjectId),
teacherId: UserId::fromString($command->teacherId),
dayOfWeek: DayOfWeek::from($command->dayOfWeek),
timeSlot: new TimeSlot($command->startTime, $command->endTime),
room: $command->room,
isRecurring: true,
now: $now,
recurrenceStart: $occurrenceDate,
recurrenceEnd: $originalRecurrenceEnd,
);
$this->slotRepository->save($newSlot);
return ['exception' => null, 'newSlot' => $newSlot];
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Service;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Scolarite\Domain\Model\Schedule\ResolvedScheduleSlot;
use App\Scolarite\Domain\Model\Schedule\ScheduleException;
use App\Scolarite\Domain\Repository\ScheduleExceptionRepository;
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
/**
* Résout l'emploi du temps réel d'une classe pour une semaine donnée.
*
* Combine les créneaux récurrents (semaine type), les exceptions ponctuelles,
* et le calendrier scolaire pour produire la liste des cours effectifs.
*/
final readonly class ScheduleResolver
{
public function __construct(
private ScheduleSlotRepository $slotRepository,
private ScheduleExceptionRepository $exceptionRepository,
) {
}
/**
* @return array<ResolvedScheduleSlot>
*/
public function resolveForWeek(
ClassId $classId,
DateTimeImmutable $weekStart,
TenantId $tenantId,
SchoolCalendar $calendar,
): array {
$slots = $this->slotRepository->findRecurringByClass($classId, $tenantId);
$weekEnd = $weekStart->modify('+6 days');
$exceptionsIndex = $this->indexExceptions(
$this->exceptionRepository->findForDateRange($tenantId, $weekStart, $weekEnd),
);
$resolved = [];
for ($day = 0; $day < 7; ++$day) {
$date = $weekStart->modify("+{$day} days");
if (!$calendar->estJourOuvre($date)) {
continue;
}
foreach ($slots as $slot) {
if (!$slot->isActiveOnDate($date)) {
continue;
}
$key = (string) $slot->id . '_' . $date->format('Y-m-d');
$exception = $exceptionsIndex[$key] ?? null;
if ($exception?->isCancelled()) {
continue;
}
$resolved[] = $exception !== null
? ResolvedScheduleSlot::fromSlotWithException($slot, $exception)
: ResolvedScheduleSlot::fromSlot($slot, $date);
}
}
return $resolved;
}
/**
* @param array<ScheduleException> $exceptions
*
* @return array<string, ScheduleException>
*/
private function indexExceptions(array $exceptions): array
{
$index = [];
foreach ($exceptions as $exception) {
$key = (string) $exception->slotId . '_' . $exception->exceptionDate->format('Y-m-d');
$index[$key] = $exception;
}
return $index;
}
}

View File

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

View File

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

View File

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

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 ScheduleExceptionId extends EntityId
{
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Schedule;
enum ScheduleExceptionType: string
{
case MODIFIED = 'modified';
case CANCELLED = 'cancelled';
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Command\CreateScheduleException\CreateScheduleExceptionCommand;
use App\Scolarite\Application\Command\CreateScheduleException\CreateScheduleExceptionHandler;
use App\Scolarite\Application\Command\TruncateSlotRecurrence\TruncateSlotRecurrenceCommand;
use App\Scolarite\Application\Command\TruncateSlotRecurrence\TruncateSlotRecurrenceHandler;
use App\Scolarite\Domain\Exception\DateExceptionInvalideException;
use App\Scolarite\Domain\Exception\ScheduleSlotNotFoundException;
use App\Scolarite\Infrastructure\Api\Resource\ScheduleOccurrenceResource;
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Symfony\Component\HttpFoundation\RequestStack;
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\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProcessorInterface<ScheduleOccurrenceResource, ScheduleOccurrenceResource>
*/
final readonly class CancelOccurrenceProcessor implements ProcessorInterface
{
public function __construct(
private CreateScheduleExceptionHandler $cancelHandler,
private TruncateSlotRecurrenceHandler $truncateHandler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
private TokenStorageInterface $tokenStorage,
private RequestStack $requestStack,
) {
}
/**
* @param ScheduleOccurrenceResource|null $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ScheduleOccurrenceResource
{
if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::DELETE)) {
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 $slotId */
$slotId = $uriVariables['id'] ?? '';
/** @var string $date */
$date = $uriVariables['date'] ?? '';
$user = $this->tokenStorage->getToken()?->getUser();
$userId = $user instanceof SecurityUser ? $user->userId() : '';
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
$scope = $this->requestStack->getCurrentRequest()?->query->getString('scope', 'this_occurrence');
try {
if ($scope === 'all_future') {
($this->truncateHandler)(new TruncateSlotRecurrenceCommand(
tenantId: $tenantId,
slotId: $slotId,
fromDate: $date,
updatedBy: $userId,
));
$type = 'truncated';
} else {
($this->cancelHandler)(new CreateScheduleExceptionCommand(
tenantId: $tenantId,
slotId: $slotId,
exceptionDate: $date,
type: 'cancelled',
createdBy: $userId,
reason: $data?->reason,
));
$type = 'cancelled';
}
$resource = new ScheduleOccurrenceResource();
$resource->id = $slotId . '_' . $date;
$resource->slotId = $slotId;
$resource->date = $date;
$resource->type = $type;
return $resource;
} catch (ScheduleSlotNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
} catch (DateExceptionInvalideException $e) {
throw new BadRequestHttpException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Command\UpdateRecurringSlot\UpdateRecurringSlotCommand;
use App\Scolarite\Application\Command\UpdateRecurringSlot\UpdateRecurringSlotHandler;
use App\Scolarite\Domain\Exception\DateExceptionInvalideException;
use App\Scolarite\Domain\Exception\ScheduleSlotNotFoundException;
use App\Scolarite\Infrastructure\Api\Resource\ScheduleOccurrenceResource;
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
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\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use ValueError;
/**
* @implements ProcessorInterface<ScheduleOccurrenceResource, ScheduleOccurrenceResource>
*/
final readonly class ModifyOccurrenceProcessor implements ProcessorInterface
{
public function __construct(
private UpdateRecurringSlotHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
private TokenStorageInterface $tokenStorage,
) {
}
/**
* @param ScheduleOccurrenceResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ScheduleOccurrenceResource
{
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 $slotId */
$slotId = $uriVariables['id'] ?? '';
/** @var string $date */
$date = $uriVariables['date'] ?? '';
$user = $this->tokenStorage->getToken()?->getUser();
$userId = $user instanceof SecurityUser ? $user->userId() : '';
try {
$command = new UpdateRecurringSlotCommand(
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
slotId: $slotId,
occurrenceDate: $date,
scope: $data->scope ?? 'this_occurrence',
classId: $data->classId ?? '',
subjectId: $data->subjectId ?? '',
teacherId: $data->teacherId ?? '',
dayOfWeek: $data->dayOfWeek ?? 1,
startTime: $data->startTime ?? '',
endTime: $data->endTime ?? '',
room: $data->room,
updatedBy: $userId,
);
$result = ($this->handler)($command);
$resource = new ScheduleOccurrenceResource();
$resource->id = $slotId . '_' . $date;
$resource->slotId = $slotId;
$resource->date = $date;
$resource->scope = $data->scope;
$resource->type = $result['exception'] !== null ? 'modified' : 'recurring_updated';
return $resource;
} catch (ScheduleSlotNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
} catch (DateExceptionInvalideException|ValueError $e) {
throw new BadRequestHttpException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Scolarite\Application\Service\ScheduleResolver;
use App\Scolarite\Infrastructure\Api\Resource\ResolvedScheduleSlotResource;
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use DateTimeImmutable;
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;
/**
* Provider pour l'emploi du temps résolu d'une semaine.
*
* @implements ProviderInterface<ResolvedScheduleSlotResource>
*/
final readonly class ResolvedScheduleWeekProvider implements ProviderInterface
{
public function __construct(
private ScheduleResolver $scheduleResolver,
private SchoolCalendarRepository $calendarRepository,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
) {
}
/** @return array<ResolvedScheduleSlotResource> */
#[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.');
}
/** @var array<string, string> $filters */
$filters = $context['filters'] ?? [];
if (!isset($filters['classId'])) {
throw new BadRequestHttpException('Le paramètre classId est requis.');
}
/** @var string $dateParam */
$dateParam = $uriVariables['date'] ?? '';
$weekStart = new DateTimeImmutable($dateParam);
$tenantId = TenantId::fromString((string) $this->tenantContext->getCurrentTenantId());
$classId = ClassId::fromString((string) $filters['classId']);
$calendar = $this->loadCalendar($tenantId);
// TODO: cache-aside par classId+weekStart si la perf le nécessite
$resolved = $this->scheduleResolver->resolveForWeek(
$classId,
$weekStart,
$tenantId,
$calendar,
);
return array_map(ResolvedScheduleSlotResource::fromDomain(...), $resolved);
}
private function loadCalendar(TenantId $tenantId): SchoolCalendar
{
$academicYearId = $this->academicYearResolver->resolve('current');
if ($academicYearId === null) {
return SchoolCalendar::initialiser($tenantId, AcademicYearId::generate());
}
$calendar = $this->calendarRepository->findByTenantAndYear(
$tenantId,
AcademicYearId::fromString($academicYearId),
);
return $calendar ?? SchoolCalendar::initialiser($tenantId, AcademicYearId::generate());
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use App\Scolarite\Domain\Model\Schedule\ResolvedScheduleSlot;
use App\Scolarite\Infrastructure\Api\Provider\ResolvedScheduleWeekProvider;
/**
* API Resource pour l'emploi du temps résolu (récurrences + exceptions + calendrier).
*
* @see Story 4.2 - Récurrences Hebdomadaires
* @see FR27 - Définir une semaine type qui se répète automatiquement
*/
#[ApiResource(
shortName: 'ResolvedScheduleSlot',
operations: [
new GetCollection(
uriTemplate: '/schedule/week/{date}',
uriVariables: [
'date' => new Link(
fromClass: self::class,
identifiers: ['date'],
),
],
provider: ResolvedScheduleWeekProvider::class,
name: 'get_resolved_schedule_week',
),
],
)]
final class ResolvedScheduleSlotResource
{
#[ApiProperty(identifier: true)]
public ?string $id = null;
public ?string $slotId = null;
public ?string $classId = null;
public ?string $subjectId = null;
public ?string $teacherId = null;
public ?int $dayOfWeek = null;
public ?string $startTime = null;
public ?string $endTime = null;
public ?string $room = null;
public ?string $date = null;
public ?bool $isModified = null;
public ?string $exceptionId = null;
public static function fromDomain(ResolvedScheduleSlot $resolved): self
{
$resource = new self();
$resource->id = (string) $resolved->slotId . '_' . $resolved->date->format('Y-m-d');
$resource->slotId = (string) $resolved->slotId;
$resource->classId = (string) $resolved->classId;
$resource->subjectId = (string) $resolved->subjectId;
$resource->teacherId = (string) $resolved->teacherId;
$resource->dayOfWeek = $resolved->dayOfWeek->value;
$resource->startTime = $resolved->timeSlot->startTime;
$resource->endTime = $resolved->timeSlot->endTime;
$resource->room = $resolved->room;
$resource->date = $resolved->date->format('Y-m-d');
$resource->isModified = $resolved->isModified;
$resource->exceptionId = $resolved->exceptionId !== null ? (string) $resolved->exceptionId : null;
return $resource;
}
}

View File

@@ -0,0 +1,71 @@
<?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\Put;
use App\Scolarite\Infrastructure\Api\Processor\CancelOccurrenceProcessor;
use App\Scolarite\Infrastructure\Api\Processor\ModifyOccurrenceProcessor;
use Symfony\Component\Validator\Constraints as Assert;
/**
* API Resource pour les modifications ponctuelles d'occurrences de cours récurrents.
*
* @see Story 4.2 - Récurrences Hebdomadaires (AC3, AC5)
*/
#[ApiResource(
shortName: 'ScheduleOccurrence',
operations: [
new Put(
uriTemplate: '/schedule/slots/{id}/occurrence/{date}',
read: false,
processor: ModifyOccurrenceProcessor::class,
name: 'modify_schedule_occurrence',
),
new Delete(
uriTemplate: '/schedule/slots/{id}/occurrence/{date}',
read: false,
processor: CancelOccurrenceProcessor::class,
name: 'cancel_schedule_occurrence',
),
],
)]
final class ScheduleOccurrenceResource
{
#[ApiProperty(identifier: true)]
public ?string $id = null;
/**
* 'this_occurrence' (modifier uniquement cette date) ou 'all_future' (toutes les futures).
*/
#[Assert\Choice(choices: ['this_occurrence', 'all_future'], message: 'Le scope doit être "this_occurrence" ou "all_future".')]
public ?string $scope = 'this_occurrence';
public ?string $classId = null;
public ?string $subjectId = null;
public ?string $teacherId = null;
public ?int $dayOfWeek = null;
#[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\Regex(pattern: '/^([01]\d|2[0-3]):[0-5]\d$/', message: "L'heure doit être au format HH:MM.")]
public ?string $endTime = null;
public ?string $room = null;
public ?string $reason = null;
public ?string $type = null;
public ?string $date = null;
public ?string $slotId = null;
}

View File

@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Persistence\Doctrine;
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\Scolarite\Domain\Repository\ScheduleExceptionRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Override;
final readonly class DoctrineScheduleExceptionRepository implements ScheduleExceptionRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(ScheduleException $exception): void
{
$this->connection->executeStatement(
'INSERT INTO schedule_exceptions (id, tenant_id, slot_id, exception_date, exception_type, new_start_time, new_end_time, new_room, new_teacher_id, reason, created_at, created_by)
VALUES (:id, :tenant_id, :slot_id, :exception_date, :exception_type, :new_start_time, :new_end_time, :new_room, :new_teacher_id, :reason, :created_at, :created_by)
ON CONFLICT (slot_id, exception_date) DO UPDATE SET
exception_type = EXCLUDED.exception_type,
new_start_time = EXCLUDED.new_start_time,
new_end_time = EXCLUDED.new_end_time,
new_room = EXCLUDED.new_room,
new_teacher_id = EXCLUDED.new_teacher_id,
reason = EXCLUDED.reason',
[
'id' => (string) $exception->id,
'tenant_id' => (string) $exception->tenantId,
'slot_id' => (string) $exception->slotId,
'exception_date' => $exception->exceptionDate->format('Y-m-d'),
'exception_type' => $exception->type->value,
'new_start_time' => $exception->newTimeSlot?->startTime,
'new_end_time' => $exception->newTimeSlot?->endTime,
'new_room' => $exception->newRoom,
'new_teacher_id' => $exception->newTeacherId !== null ? (string) $exception->newTeacherId : null,
'reason' => $exception->reason,
'created_at' => $exception->createdAt->format(DateTimeImmutable::ATOM),
'created_by' => (string) $exception->createdBy,
],
);
}
#[Override]
public function findById(ScheduleExceptionId $id, TenantId $tenantId): ?ScheduleException
{
$row = $this->connection->fetchAssociative(
'SELECT * FROM schedule_exceptions 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 findForSlotAndDate(
ScheduleSlotId $slotId,
DateTimeImmutable $date,
TenantId $tenantId,
): ?ScheduleException {
$row = $this->connection->fetchAssociative(
'SELECT * FROM schedule_exceptions
WHERE slot_id = :slot_id AND exception_date = :exception_date AND tenant_id = :tenant_id',
[
'slot_id' => (string) $slotId,
'exception_date' => $date->format('Y-m-d'),
'tenant_id' => (string) $tenantId,
],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function findForSlotBetweenDates(
ScheduleSlotId $slotId,
DateTimeImmutable $startDate,
DateTimeImmutable $endDate,
TenantId $tenantId,
): array {
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM schedule_exceptions
WHERE slot_id = :slot_id
AND exception_date >= :start_date
AND exception_date <= :end_date
AND tenant_id = :tenant_id
ORDER BY exception_date',
[
'slot_id' => (string) $slotId,
'start_date' => $startDate->format('Y-m-d'),
'end_date' => $endDate->format('Y-m-d'),
'tenant_id' => (string) $tenantId,
],
);
return array_map($this->hydrate(...), $rows);
}
#[Override]
public function findForDateRange(
TenantId $tenantId,
DateTimeImmutable $startDate,
DateTimeImmutable $endDate,
): array {
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM schedule_exceptions
WHERE tenant_id = :tenant_id
AND exception_date >= :start_date
AND exception_date <= :end_date
ORDER BY exception_date',
[
'tenant_id' => (string) $tenantId,
'start_date' => $startDate->format('Y-m-d'),
'end_date' => $endDate->format('Y-m-d'),
],
);
return array_map($this->hydrate(...), $rows);
}
#[Override]
public function delete(ScheduleExceptionId $id, TenantId $tenantId): void
{
$this->connection->executeStatement(
'DELETE FROM schedule_exceptions WHERE id = :id AND tenant_id = :tenant_id',
['id' => (string) $id, 'tenant_id' => (string) $tenantId],
);
}
/**
* @param array<string, mixed> $row
*/
private function hydrate(array $row): ScheduleException
{
/** @var string $id */
$id = $row['id'];
/** @var string $tenantId */
$tenantId = $row['tenant_id'];
/** @var string $slotId */
$slotId = $row['slot_id'];
/** @var string $exceptionDate */
$exceptionDate = $row['exception_date'];
/** @var string $exceptionType */
$exceptionType = $row['exception_type'];
/** @var string|null $newStartTime */
$newStartTime = $row['new_start_time'] ?? null;
/** @var string|null $newEndTime */
$newEndTime = $row['new_end_time'] ?? null;
/** @var string|null $newRoom */
$newRoom = $row['new_room'] ?? null;
/** @var string|null $newTeacherId */
$newTeacherId = $row['new_teacher_id'] ?? null;
/** @var string|null $reason */
$reason = $row['reason'] ?? null;
/** @var string $createdBy */
$createdBy = $row['created_by'];
/** @var string $createdAt */
$createdAt = $row['created_at'];
return ScheduleException::reconstitute(
id: ScheduleExceptionId::fromString($id),
tenantId: TenantId::fromString($tenantId),
slotId: ScheduleSlotId::fromString($slotId),
exceptionDate: new DateTimeImmutable($exceptionDate),
type: ScheduleExceptionType::from($exceptionType),
newTimeSlot: $newStartTime !== null && $newEndTime !== null
? new TimeSlot($newStartTime, $newEndTime)
: null,
newRoom: $newRoom,
newTeacherId: $newTeacherId !== null ? UserId::fromString($newTeacherId) : null,
reason: $reason,
createdBy: UserId::fromString($createdBy),
createdAt: new DateTimeImmutable($createdAt),
);
}
}

View File

@@ -32,8 +32,8 @@ final readonly class DoctrineScheduleSlotRepository implements ScheduleSlotRepos
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)
'INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, recurrence_start, recurrence_end, created_at, updated_at)
VALUES (:id, :tenant_id, :class_id, :subject_id, :teacher_id, :day_of_week, :start_time, :end_time, :room, :is_recurring, :recurrence_start, :recurrence_end, :created_at, :updated_at)
ON CONFLICT (id) DO UPDATE SET
class_id = EXCLUDED.class_id,
subject_id = EXCLUDED.subject_id,
@@ -42,6 +42,8 @@ final readonly class DoctrineScheduleSlotRepository implements ScheduleSlotRepos
start_time = EXCLUDED.start_time,
end_time = EXCLUDED.end_time,
room = EXCLUDED.room,
recurrence_start = EXCLUDED.recurrence_start,
recurrence_end = EXCLUDED.recurrence_end,
updated_at = EXCLUDED.updated_at',
[
'id' => (string) $slot->id,
@@ -54,6 +56,8 @@ final readonly class DoctrineScheduleSlotRepository implements ScheduleSlotRepos
'end_time' => $slot->timeSlot->endTime,
'room' => $slot->room,
'is_recurring' => $slot->isRecurring ? 'true' : 'false',
'recurrence_start' => $slot->recurrenceStart?->format('Y-m-d'),
'recurrence_end' => $slot->recurrenceEnd?->format('Y-m-d'),
'created_at' => $slot->createdAt->format(DateTimeImmutable::ATOM),
'updated_at' => $slot->updatedAt->format(DateTimeImmutable::ATOM),
],
@@ -109,6 +113,19 @@ final readonly class DoctrineScheduleSlotRepository implements ScheduleSlotRepos
return $this->hydrateMany($rows);
}
#[Override]
public function findRecurringByClass(ClassId $classId, TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM schedule_slots
WHERE class_id = :class_id AND tenant_id = :tenant_id AND is_recurring = true
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
{
@@ -256,6 +273,10 @@ final readonly class DoctrineScheduleSlotRepository implements ScheduleSlotRepos
$room = $row['room'];
/** @var bool $isRecurring */
$isRecurring = $row['is_recurring'];
/** @var string|null $recurrenceStart */
$recurrenceStart = $row['recurrence_start'] ?? null;
/** @var string|null $recurrenceEnd */
$recurrenceEnd = $row['recurrence_end'] ?? null;
/** @var string $createdAt */
$createdAt = $row['created_at'];
/** @var string $updatedAt */
@@ -273,6 +294,8 @@ final readonly class DoctrineScheduleSlotRepository implements ScheduleSlotRepos
isRecurring: (bool) $isRecurring,
createdAt: new DateTimeImmutable($createdAt),
updatedAt: new DateTimeImmutable($updatedAt),
recurrenceStart: $recurrenceStart !== null ? new DateTimeImmutable($recurrenceStart) : null,
recurrenceEnd: $recurrenceEnd !== null ? new DateTimeImmutable($recurrenceEnd) : null,
);
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Persistence\InMemory;
use App\Scolarite\Domain\Model\Schedule\ScheduleException;
use App\Scolarite\Domain\Model\Schedule\ScheduleExceptionId;
use App\Scolarite\Domain\Model\Schedule\ScheduleSlotId;
use App\Scolarite\Domain\Repository\ScheduleExceptionRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_filter;
use function array_values;
use DateTimeImmutable;
use Override;
final class InMemoryScheduleExceptionRepository implements ScheduleExceptionRepository
{
/** @var array<string, ScheduleException> */
private array $byId = [];
#[Override]
public function save(ScheduleException $exception): void
{
$this->byId[(string) $exception->id] = $exception;
}
#[Override]
public function findById(ScheduleExceptionId $id, TenantId $tenantId): ?ScheduleException
{
$exception = $this->byId[(string) $id] ?? null;
if ($exception !== null && !$exception->tenantId->equals($tenantId)) {
return null;
}
return $exception;
}
#[Override]
public function findForSlotAndDate(
ScheduleSlotId $slotId,
DateTimeImmutable $date,
TenantId $tenantId,
): ?ScheduleException {
$dateStr = $date->format('Y-m-d');
foreach ($this->byId as $exception) {
if ($exception->tenantId->equals($tenantId)
&& $exception->slotId->equals($slotId)
&& $exception->exceptionDate->format('Y-m-d') === $dateStr
) {
return $exception;
}
}
return null;
}
#[Override]
public function findForSlotBetweenDates(
ScheduleSlotId $slotId,
DateTimeImmutable $startDate,
DateTimeImmutable $endDate,
TenantId $tenantId,
): array {
$start = $startDate->format('Y-m-d');
$end = $endDate->format('Y-m-d');
return array_values(array_filter(
$this->byId,
static fn (ScheduleException $e): bool => $e->tenantId->equals($tenantId)
&& $e->slotId->equals($slotId)
&& $e->exceptionDate->format('Y-m-d') >= $start
&& $e->exceptionDate->format('Y-m-d') <= $end,
));
}
#[Override]
public function findForDateRange(
TenantId $tenantId,
DateTimeImmutable $startDate,
DateTimeImmutable $endDate,
): array {
$start = $startDate->format('Y-m-d');
$end = $endDate->format('Y-m-d');
return array_values(array_filter(
$this->byId,
static fn (ScheduleException $e): bool => $e->tenantId->equals($tenantId)
&& $e->exceptionDate->format('Y-m-d') >= $start
&& $e->exceptionDate->format('Y-m-d') <= $end,
));
}
#[Override]
public function delete(ScheduleExceptionId $id, TenantId $tenantId): void
{
$exception = $this->findById($id, $tenantId);
if ($exception !== null) {
unset($this->byId[(string) $id]);
}
}
}

View File

@@ -74,6 +74,17 @@ final class InMemoryScheduleSlotRepository implements ScheduleSlotRepository
));
}
#[Override]
public function findRecurringByClass(ClassId $classId, TenantId $tenantId): array
{
return array_values(array_filter(
$this->byId,
static fn (ScheduleSlot $s) => $s->tenantId->equals($tenantId)
&& $s->classId->equals($classId)
&& $s->isRecurring,
));
}
#[Override]
public function findByTeacher(UserId $teacherId, TenantId $tenantId): array
{