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