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:
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\CreateScheduleSlot;
|
||||
|
||||
final readonly class CreateScheduleSlotCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $classId,
|
||||
public string $subjectId,
|
||||
public string $teacherId,
|
||||
public int $dayOfWeek,
|
||||
public string $startTime,
|
||||
public string $endTime,
|
||||
public ?string $room,
|
||||
public bool $isRecurring = true,
|
||||
public bool $forceConflicts = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\CreateScheduleSlot;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
||||
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
|
||||
use App\Scolarite\Domain\Service\ScheduleConflict;
|
||||
use App\Scolarite\Domain\Service\ScheduleConflictDetector;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class CreateScheduleSlotHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduleSlotRepository $repository,
|
||||
private ScheduleConflictDetector $conflictDetector,
|
||||
private EnseignantAffectationChecker $affectationChecker,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{slot: ScheduleSlot, conflicts: array<ScheduleConflict>}
|
||||
*/
|
||||
public function __invoke(CreateScheduleSlotCommand $command): array
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$classId = ClassId::fromString($command->classId);
|
||||
$subjectId = SubjectId::fromString($command->subjectId);
|
||||
$teacherId = UserId::fromString($command->teacherId);
|
||||
$timeSlot = new TimeSlot($command->startTime, $command->endTime);
|
||||
|
||||
if (!$this->affectationChecker->estAffecte($teacherId, $classId, $subjectId, $tenantId)) {
|
||||
throw EnseignantNonAffecteException::pourClasseEtMatiere($teacherId, $classId, $subjectId);
|
||||
}
|
||||
|
||||
$slot = ScheduleSlot::creer(
|
||||
tenantId: $tenantId,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: DayOfWeek::from($command->dayOfWeek),
|
||||
timeSlot: $timeSlot,
|
||||
room: $command->room,
|
||||
isRecurring: $command->isRecurring,
|
||||
now: $this->clock->now(),
|
||||
);
|
||||
|
||||
$conflicts = $this->conflictDetector->detectConflicts($slot, $tenantId);
|
||||
|
||||
if ($conflicts === [] || $command->forceConflicts) {
|
||||
$this->repository->save($slot);
|
||||
}
|
||||
|
||||
return ['slot' => $slot, 'conflicts' => $conflicts];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\DeleteScheduleSlot;
|
||||
|
||||
final readonly class DeleteScheduleSlotCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $slotId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\DeleteScheduleSlot;
|
||||
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
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 Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class DeleteScheduleSlotHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduleSlotRepository $repository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ScheduleSlot Le slot supprimé (pour dispatch events)
|
||||
*/
|
||||
public function __invoke(DeleteScheduleSlotCommand $command): ScheduleSlot
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$slotId = ScheduleSlotId::fromString($command->slotId);
|
||||
|
||||
$slot = $this->repository->get($slotId, $tenantId);
|
||||
|
||||
$slot->supprimer($this->clock->now());
|
||||
$this->repository->delete($slotId, $tenantId);
|
||||
|
||||
return $slot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\UpdateScheduleSlot;
|
||||
|
||||
final readonly class UpdateScheduleSlotCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $slotId,
|
||||
public string $classId,
|
||||
public string $subjectId,
|
||||
public string $teacherId,
|
||||
public int $dayOfWeek,
|
||||
public string $startTime,
|
||||
public string $endTime,
|
||||
public ?string $room,
|
||||
public bool $forceConflicts = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\UpdateScheduleSlot;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
||||
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
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\ScheduleSlotRepository;
|
||||
use App\Scolarite\Domain\Service\ScheduleConflict;
|
||||
use App\Scolarite\Domain\Service\ScheduleConflictDetector;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class UpdateScheduleSlotHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduleSlotRepository $repository,
|
||||
private ScheduleConflictDetector $conflictDetector,
|
||||
private EnseignantAffectationChecker $affectationChecker,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{slot: ScheduleSlot, conflicts: array<ScheduleConflict>}
|
||||
*/
|
||||
public function __invoke(UpdateScheduleSlotCommand $command): array
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$slotId = ScheduleSlotId::fromString($command->slotId);
|
||||
|
||||
$slot = $this->repository->get($slotId, $tenantId);
|
||||
|
||||
$classId = ClassId::fromString($command->classId);
|
||||
$subjectId = SubjectId::fromString($command->subjectId);
|
||||
$teacherId = UserId::fromString($command->teacherId);
|
||||
$dayOfWeek = DayOfWeek::from($command->dayOfWeek);
|
||||
$newTimeSlot = new TimeSlot($command->startTime, $command->endTime);
|
||||
|
||||
if (!$this->affectationChecker->estAffecte($teacherId, $classId, $subjectId, $tenantId)) {
|
||||
throw EnseignantNonAffecteException::pourClasseEtMatiere($teacherId, $classId, $subjectId);
|
||||
}
|
||||
|
||||
// Détecte les conflits avant de modifier le slot.
|
||||
// On crée un slot temporaire avec les nouvelles valeurs pour la détection.
|
||||
$preview = ScheduleSlot::creer(
|
||||
tenantId: $tenantId,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: $dayOfWeek,
|
||||
timeSlot: $newTimeSlot,
|
||||
room: $command->room,
|
||||
isRecurring: $slot->isRecurring,
|
||||
now: $this->clock->now(),
|
||||
);
|
||||
$conflicts = $this->conflictDetector->detectConflicts($preview, $tenantId, $slotId);
|
||||
|
||||
if ($conflicts === [] || $command->forceConflicts) {
|
||||
$slot->modifier(
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
dayOfWeek: $dayOfWeek,
|
||||
timeSlot: $newTimeSlot,
|
||||
room: $command->room,
|
||||
at: $this->clock->now(),
|
||||
);
|
||||
$this->repository->save($slot);
|
||||
}
|
||||
|
||||
return ['slot' => $slot, 'conflicts' => $conflicts];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Port;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Port pour vérifier qu'un enseignant est affecté à une classe/matière.
|
||||
*
|
||||
* L'implémentation résout l'année académique courante de façon transparente.
|
||||
*/
|
||||
interface EnseignantAffectationChecker
|
||||
{
|
||||
public function estAffecte(
|
||||
UserId $teacherId,
|
||||
ClassId $classId,
|
||||
SubjectId $subjectId,
|
||||
TenantId $tenantId,
|
||||
): bool;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetBlockedDates;
|
||||
|
||||
final readonly class BlockedDateDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $date,
|
||||
public string $reason,
|
||||
public string $type,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetBlockedDates;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Retourne les dates bloquées (jours fériés, vacances, journées pédagogiques, weekends)
|
||||
* pour une plage de dates donnée.
|
||||
*
|
||||
* Utilisé par le frontend pour griser les jours non modifiables dans la grille EDT.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetBlockedDatesHandler
|
||||
{
|
||||
public function __construct(
|
||||
private SchoolCalendarRepository $calendarRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<BlockedDateDto> */
|
||||
public function __invoke(GetBlockedDatesQuery $query): array
|
||||
{
|
||||
$tenantId = TenantId::fromString($query->tenantId);
|
||||
$academicYearId = AcademicYearId::fromString($query->academicYearId);
|
||||
|
||||
$calendar = $this->calendarRepository->findByTenantAndYear($tenantId, $academicYearId);
|
||||
|
||||
$startDate = new DateTimeImmutable($query->startDate);
|
||||
$endDate = new DateTimeImmutable($query->endDate);
|
||||
$oneDay = new DateInterval('P1D');
|
||||
|
||||
$blockedDates = [];
|
||||
$current = $startDate;
|
||||
|
||||
while ($current <= $endDate) {
|
||||
$dayOfWeek = (int) $current->format('N');
|
||||
$dateStr = $current->format('Y-m-d');
|
||||
|
||||
if ($dayOfWeek >= 6) {
|
||||
$blockedDates[] = new BlockedDateDto(
|
||||
date: $dateStr,
|
||||
reason: $dayOfWeek === 6 ? 'Samedi' : 'Dimanche',
|
||||
type: 'weekend',
|
||||
);
|
||||
} elseif ($calendar !== null) {
|
||||
$entry = $calendar->trouverEntreePourDate($current);
|
||||
|
||||
if ($entry !== null) {
|
||||
$blockedDates[] = new BlockedDateDto(
|
||||
date: $dateStr,
|
||||
reason: $entry->label,
|
||||
type: $entry->type->value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$current = $current->add($oneDay);
|
||||
}
|
||||
|
||||
return $blockedDates;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetBlockedDates;
|
||||
|
||||
final readonly class GetBlockedDatesQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $academicYearId,
|
||||
public string $startDate,
|
||||
public string $endDate,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetScheduleSlots;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Repository\ScheduleSlotRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_filter;
|
||||
use function array_map;
|
||||
use function array_values;
|
||||
|
||||
use Ramsey\Uuid\Exception\InvalidUuidStringException;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetScheduleSlotsHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduleSlotRepository $repository,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<ScheduleSlotDto> */
|
||||
public function __invoke(GetScheduleSlotsQuery $query): array
|
||||
{
|
||||
try {
|
||||
$tenantId = TenantId::fromString($query->tenantId);
|
||||
|
||||
if ($query->classId !== null) {
|
||||
$slots = $this->repository->findByClass(ClassId::fromString($query->classId), $tenantId);
|
||||
|
||||
if ($query->teacherId !== null) {
|
||||
$teacherId = UserId::fromString($query->teacherId);
|
||||
$slots = array_values(array_filter(
|
||||
$slots,
|
||||
static fn (ScheduleSlot $slot) => $slot->teacherId->equals($teacherId),
|
||||
));
|
||||
}
|
||||
} elseif ($query->teacherId !== null) {
|
||||
$slots = $this->repository->findByTeacher(UserId::fromString($query->teacherId), $tenantId);
|
||||
} else {
|
||||
$slots = [];
|
||||
}
|
||||
} catch (InvalidUuidStringException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(ScheduleSlotDto::fromDomain(...), $slots);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetScheduleSlots;
|
||||
|
||||
final readonly class GetScheduleSlotsQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public ?string $classId = null,
|
||||
public ?string $teacherId = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetScheduleSlots;
|
||||
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class ScheduleSlotDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $classId,
|
||||
public string $subjectId,
|
||||
public string $teacherId,
|
||||
public int $dayOfWeek,
|
||||
public string $startTime,
|
||||
public string $endTime,
|
||||
public ?string $room,
|
||||
public bool $isRecurring,
|
||||
public DateTimeImmutable $createdAt,
|
||||
public DateTimeImmutable $updatedAt,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromDomain(ScheduleSlot $slot): self
|
||||
{
|
||||
return new self(
|
||||
id: (string) $slot->id,
|
||||
classId: (string) $slot->classId,
|
||||
subjectId: (string) $slot->subjectId,
|
||||
teacherId: (string) $slot->teacherId,
|
||||
dayOfWeek: $slot->dayOfWeek->value,
|
||||
startTime: $slot->timeSlot->startTime,
|
||||
endTime: $slot->timeSlot->endTime,
|
||||
room: $slot->room,
|
||||
isRecurring: $slot->isRecurring,
|
||||
createdAt: $slot->createdAt,
|
||||
updatedAt: $slot->updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user