feat: Gestion des périodes scolaires
L'administration d'un établissement nécessite de découper l'année scolaire en trimestres ou semestres avant de pouvoir saisir les notes et générer les bulletins. Ce module permet de configurer les périodes par année scolaire (current/previous/next résolus en UUID v5 déterministes), de modifier les dates individuelles avec validation anti-chevauchement, et de consulter la période en cours avec le décompte des jours restants. Les dates par défaut de février s'adaptent aux années bissextiles. Le repository utilise UPSERT transactionnel pour garantir l'intégrité lors du changement de mode (trimestres ↔ semestres). Les domain events de Subject sont étendus pour couvrir toutes les mutations (code, couleur, description) en plus du renommage.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Command\ConfigurePeriods\ConfigurePeriodsCommand;
|
||||
use App\Administration\Application\Command\ConfigurePeriods\ConfigurePeriodsHandler;
|
||||
use App\Administration\Domain\Exception\PeriodesDejaConfigureesException;
|
||||
use App\Administration\Infrastructure\Api\Resource\PeriodItem;
|
||||
use App\Administration\Infrastructure\Api\Resource\PeriodResource;
|
||||
use App\Administration\Infrastructure\Security\PeriodVoter;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* Processor API Platform pour configurer les périodes d'une année scolaire.
|
||||
*
|
||||
* @implements ProcessorInterface<PeriodResource, PeriodResource>
|
||||
*/
|
||||
final readonly class ConfigurePeriodsProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ConfigurePeriodsHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private CurrentAcademicYearResolver $academicYearResolver,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PeriodResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): PeriodResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(PeriodVoter::CONFIGURE)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à configurer les périodes.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
/** @var string $rawAcademicYearId */
|
||||
$rawAcademicYearId = $uriVariables['academicYearId'];
|
||||
|
||||
$academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId);
|
||||
if ($academicYearId === null) {
|
||||
throw new NotFoundHttpException('Année scolaire non trouvée.');
|
||||
}
|
||||
|
||||
try {
|
||||
$startYear = $data->startYear
|
||||
?? $this->academicYearResolver->resolveStartYear($rawAcademicYearId)
|
||||
?? (int) $this->clock->now()->format('Y');
|
||||
|
||||
$command = new ConfigurePeriodsCommand(
|
||||
tenantId: $tenantId,
|
||||
academicYearId: $academicYearId,
|
||||
periodType: $data->periodType ?? 'trimester',
|
||||
startYear: $startYear,
|
||||
);
|
||||
|
||||
$config = ($this->handler)($command);
|
||||
|
||||
$resource = new PeriodResource();
|
||||
$resource->academicYearId = $academicYearId;
|
||||
$resource->type = $config->type->value;
|
||||
$resource->periods = [];
|
||||
|
||||
foreach ($config->periods as $period) {
|
||||
$item = new PeriodItem();
|
||||
$item->sequence = $period->sequence;
|
||||
$item->label = $period->label;
|
||||
$item->startDate = $period->startDate->format('Y-m-d');
|
||||
$item->endDate = $period->endDate->format('Y-m-d');
|
||||
$resource->periods[] = $item;
|
||||
}
|
||||
|
||||
return $resource;
|
||||
} catch (PeriodesDejaConfigureesException $e) {
|
||||
throw new ConflictHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,18 +73,7 @@ final readonly class CreateSubjectProcessor implements ProcessorInterface
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
// Return the created resource
|
||||
$resource = new SubjectResource();
|
||||
$resource->id = (string) $subject->id;
|
||||
$resource->name = (string) $subject->name;
|
||||
$resource->code = (string) $subject->code;
|
||||
$resource->color = $subject->color !== null ? (string) $subject->color : null;
|
||||
$resource->description = $subject->description;
|
||||
$resource->status = $subject->status->value;
|
||||
$resource->createdAt = $subject->createdAt;
|
||||
$resource->updatedAt = $subject->updatedAt;
|
||||
|
||||
return $resource;
|
||||
return SubjectResource::fromDomain($subject);
|
||||
} catch (SubjectNameInvalideException|SubjectCodeInvalideException|SubjectColorInvalideException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
} catch (SubjectDejaExistanteException $e) {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Command\UpdatePeriod\UpdatePeriodCommand;
|
||||
use App\Administration\Application\Command\UpdatePeriod\UpdatePeriodHandler;
|
||||
use App\Administration\Domain\Exception\InvalidPeriodDatesException;
|
||||
use App\Administration\Domain\Exception\PeriodeAvecNotesException;
|
||||
use App\Administration\Domain\Exception\PeriodeNonTrouveeException;
|
||||
use App\Administration\Domain\Exception\PeriodesNonConfigureesException;
|
||||
use App\Administration\Domain\Exception\PeriodsCoverageGapException;
|
||||
use App\Administration\Domain\Exception\PeriodsOverlapException;
|
||||
use App\Administration\Infrastructure\Api\Resource\PeriodItem;
|
||||
use App\Administration\Infrastructure\Api\Resource\PeriodResource;
|
||||
use App\Administration\Infrastructure\Security\PeriodVoter;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
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\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* Processor API Platform pour modifier les dates d'une période.
|
||||
*
|
||||
* @implements ProcessorInterface<PeriodResource, PeriodResource>
|
||||
*/
|
||||
final readonly class UpdatePeriodProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private UpdatePeriodHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private CurrentAcademicYearResolver $academicYearResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PeriodResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): PeriodResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(PeriodVoter::CONFIGURE)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à modifier les périodes.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
/** @var string $rawAcademicYearId */
|
||||
$rawAcademicYearId = $uriVariables['academicYearId'];
|
||||
|
||||
$academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId);
|
||||
if ($academicYearId === null) {
|
||||
throw new NotFoundHttpException('Année scolaire non trouvée.');
|
||||
}
|
||||
|
||||
/** @var int|string $sequence */
|
||||
$sequence = $uriVariables['sequence'];
|
||||
|
||||
$startDate = $data->startDate;
|
||||
$endDate = $data->endDate;
|
||||
|
||||
if ($startDate === null || $startDate === '' || $endDate === null || $endDate === '') {
|
||||
throw new BadRequestHttpException('Les dates de début et de fin sont obligatoires.');
|
||||
}
|
||||
|
||||
try {
|
||||
$command = new UpdatePeriodCommand(
|
||||
tenantId: $tenantId,
|
||||
academicYearId: $academicYearId,
|
||||
sequence: (int) $sequence,
|
||||
startDate: $startDate,
|
||||
endDate: $endDate,
|
||||
confirmImpact: $data->confirmImpact ?? false,
|
||||
);
|
||||
|
||||
$config = ($this->handler)($command);
|
||||
|
||||
$resource = new PeriodResource();
|
||||
$resource->academicYearId = $academicYearId;
|
||||
$resource->type = $config->type->value;
|
||||
$resource->periods = [];
|
||||
|
||||
foreach ($config->periods as $period) {
|
||||
$item = new PeriodItem();
|
||||
$item->sequence = $period->sequence;
|
||||
$item->label = $period->label;
|
||||
$item->startDate = $period->startDate->format('Y-m-d');
|
||||
$item->endDate = $period->endDate->format('Y-m-d');
|
||||
$resource->periods[] = $item;
|
||||
}
|
||||
|
||||
return $resource;
|
||||
} catch (PeriodesNonConfigureesException|PeriodeNonTrouveeException $e) {
|
||||
throw new NotFoundHttpException($e->getMessage());
|
||||
} catch (PeriodeAvecNotesException $e) {
|
||||
throw new ConflictHttpException($e->getMessage());
|
||||
} catch (InvalidPeriodDatesException|PeriodsOverlapException|PeriodsCoverageGapException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,18 +86,7 @@ final readonly class UpdateSubjectProcessor implements ProcessorInterface
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
// Return the updated resource
|
||||
$resource = new SubjectResource();
|
||||
$resource->id = (string) $subject->id;
|
||||
$resource->name = (string) $subject->name;
|
||||
$resource->code = (string) $subject->code;
|
||||
$resource->color = $subject->color !== null ? (string) $subject->color : null;
|
||||
$resource->description = $subject->description;
|
||||
$resource->status = $subject->status->value;
|
||||
$resource->createdAt = $subject->createdAt;
|
||||
$resource->updatedAt = $subject->updatedAt;
|
||||
|
||||
return $resource;
|
||||
return SubjectResource::fromDomain($subject);
|
||||
} catch (SubjectNotFoundException|InvalidUuidStringException) {
|
||||
throw new NotFoundHttpException('Matière non trouvée.');
|
||||
} catch (SubjectNameInvalideException|SubjectCodeInvalideException|SubjectColorInvalideException $e) {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Application\Query\GetPeriods\GetPeriodsHandler;
|
||||
use App\Administration\Application\Query\GetPeriods\GetPeriodsQuery;
|
||||
use App\Administration\Infrastructure\Api\Resource\PeriodItem;
|
||||
use App\Administration\Infrastructure\Api\Resource\PeriodResource;
|
||||
use App\Administration\Infrastructure\Security\PeriodVoter;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* State Provider pour récupérer la configuration des périodes.
|
||||
*
|
||||
* @implements ProviderInterface<PeriodResource>
|
||||
*/
|
||||
final readonly class PeriodsProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GetPeriodsHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private CurrentAcademicYearResolver $academicYearResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?PeriodResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(PeriodVoter::VIEW)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les périodes.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
/** @var string $rawAcademicYearId */
|
||||
$rawAcademicYearId = $uriVariables['academicYearId'];
|
||||
|
||||
$academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId);
|
||||
if ($academicYearId === null) {
|
||||
throw new NotFoundHttpException('Année scolaire non trouvée.');
|
||||
}
|
||||
|
||||
$result = ($this->handler)(new GetPeriodsQuery(
|
||||
tenantId: $tenantId,
|
||||
academicYearId: $academicYearId,
|
||||
));
|
||||
|
||||
if ($result === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$resource = new PeriodResource();
|
||||
$resource->academicYearId = $academicYearId;
|
||||
$resource->type = $result->type;
|
||||
$resource->periods = [];
|
||||
|
||||
foreach ($result->periods as $periodDto) {
|
||||
$item = new PeriodItem();
|
||||
$item->sequence = $periodDto->sequence;
|
||||
$item->label = $periodDto->label;
|
||||
$item->startDate = $periodDto->startDate;
|
||||
$item->endDate = $periodDto->endDate;
|
||||
$item->isCurrent = $periodDto->isCurrent;
|
||||
$item->daysRemaining = $periodDto->daysRemaining;
|
||||
$item->isPast = $periodDto->isPast;
|
||||
$resource->periods[] = $item;
|
||||
}
|
||||
|
||||
if ($result->currentPeriod !== null) {
|
||||
$current = new PeriodItem();
|
||||
$current->sequence = $result->currentPeriod->sequence;
|
||||
$current->label = $result->currentPeriod->label;
|
||||
$current->startDate = $result->currentPeriod->startDate;
|
||||
$current->endDate = $result->currentPeriod->endDate;
|
||||
$current->isCurrent = true;
|
||||
$current->daysRemaining = $result->currentPeriod->daysRemaining;
|
||||
$resource->currentPeriod = $current;
|
||||
}
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -60,23 +60,6 @@ final readonly class SubjectCollectionProvider implements ProviderInterface
|
||||
|
||||
$subjectDtos = ($this->handler)($query);
|
||||
|
||||
return array_map(
|
||||
static function ($dto) {
|
||||
$resource = new SubjectResource();
|
||||
$resource->id = $dto->id;
|
||||
$resource->name = $dto->name;
|
||||
$resource->code = $dto->code;
|
||||
$resource->color = $dto->color;
|
||||
$resource->description = $dto->description;
|
||||
$resource->status = $dto->status;
|
||||
$resource->createdAt = $dto->createdAt;
|
||||
$resource->updatedAt = $dto->updatedAt;
|
||||
$resource->teacherCount = $dto->teacherCount;
|
||||
$resource->classCount = $dto->classCount;
|
||||
|
||||
return $resource;
|
||||
},
|
||||
$subjectDtos,
|
||||
);
|
||||
return array_map(SubjectResource::fromDto(...), $subjectDtos);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,16 +62,6 @@ final readonly class SubjectItemProvider implements ProviderInterface
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir cette matière.');
|
||||
}
|
||||
|
||||
$resource = new SubjectResource();
|
||||
$resource->id = (string) $subject->id;
|
||||
$resource->name = (string) $subject->name;
|
||||
$resource->code = (string) $subject->code;
|
||||
$resource->color = $subject->color !== null ? (string) $subject->color : null;
|
||||
$resource->description = $subject->description;
|
||||
$resource->status = $subject->status->value;
|
||||
$resource->createdAt = $subject->createdAt;
|
||||
$resource->updatedAt = $subject->updatedAt;
|
||||
|
||||
return $resource;
|
||||
return SubjectResource::fromDomain($subject);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Resource;
|
||||
|
||||
/**
|
||||
* DTO pour une période individuelle dans la réponse API.
|
||||
*
|
||||
* Séparé de PeriodResource pour éviter qu'API Platform
|
||||
* ne tente de générer un IRI pour chaque période.
|
||||
*/
|
||||
final class PeriodItem
|
||||
{
|
||||
public ?int $sequence = null;
|
||||
|
||||
public ?string $label = null;
|
||||
|
||||
public ?string $startDate = null;
|
||||
|
||||
public ?string $endDate = null;
|
||||
|
||||
public ?bool $isCurrent = null;
|
||||
|
||||
public ?int $daysRemaining = null;
|
||||
|
||||
public ?bool $isPast = null;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Administration\Infrastructure\Api\Processor\ConfigurePeriodsProcessor;
|
||||
use App\Administration\Infrastructure\Api\Processor\UpdatePeriodProcessor;
|
||||
use App\Administration\Infrastructure\Api\Provider\PeriodsProvider;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* API Resource pour la gestion des périodes scolaires.
|
||||
*
|
||||
* @see FR75 - Structurer l'année pour bulletins et moyennes
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'Period',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/academic-years/{academicYearId}/periods',
|
||||
provider: PeriodsProvider::class,
|
||||
name: 'get_periods',
|
||||
),
|
||||
new Put(
|
||||
uriTemplate: '/academic-years/{academicYearId}/periods',
|
||||
read: false,
|
||||
processor: ConfigurePeriodsProcessor::class,
|
||||
name: 'configure_periods',
|
||||
),
|
||||
new Patch(
|
||||
uriTemplate: '/academic-years/{academicYearId}/periods/{sequence}',
|
||||
read: false,
|
||||
processor: UpdatePeriodProcessor::class,
|
||||
name: 'update_period',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class PeriodResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public ?string $academicYearId = null;
|
||||
|
||||
public ?int $sequence = null;
|
||||
|
||||
#[Assert\Choice(choices: ['trimester', 'semester'], message: 'Le type de période doit être "trimester" ou "semester".')]
|
||||
public ?string $periodType = null;
|
||||
|
||||
public ?int $startYear = null;
|
||||
|
||||
#[Assert\Date(message: 'La date de début doit être une date valide (YYYY-MM-DD).')]
|
||||
public ?string $startDate = null;
|
||||
|
||||
#[Assert\Date(message: 'La date de fin doit être une date valide (YYYY-MM-DD).')]
|
||||
public ?string $endDate = null;
|
||||
|
||||
public ?string $label = null;
|
||||
|
||||
public ?bool $isCurrent = null;
|
||||
|
||||
public ?int $daysRemaining = null;
|
||||
|
||||
public ?bool $isPast = null;
|
||||
|
||||
public ?bool $confirmImpact = null;
|
||||
|
||||
/** @var PeriodItem[]|null */
|
||||
public ?array $periods = null;
|
||||
|
||||
public ?string $type = null;
|
||||
|
||||
public ?PeriodItem $currentPeriod = null;
|
||||
}
|
||||
@@ -11,6 +11,8 @@ use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Application\Query\GetSubjects\SubjectDto;
|
||||
use App\Administration\Domain\Model\Subject\Subject;
|
||||
use App\Administration\Infrastructure\Api\Processor\CreateSubjectProcessor;
|
||||
use App\Administration\Infrastructure\Api\Processor\DeleteSubjectProcessor;
|
||||
use App\Administration\Infrastructure\Api\Processor\UpdateSubjectProcessor;
|
||||
@@ -127,4 +129,42 @@ final class SubjectResource
|
||||
*/
|
||||
#[ApiProperty(readable: true, writable: false)]
|
||||
public ?bool $hasGrades = null;
|
||||
|
||||
/**
|
||||
* Crée un SubjectResource à partir du domain model.
|
||||
*/
|
||||
public static function fromDomain(Subject $subject): self
|
||||
{
|
||||
$resource = new self();
|
||||
$resource->id = (string) $subject->id;
|
||||
$resource->name = (string) $subject->name;
|
||||
$resource->code = (string) $subject->code;
|
||||
$resource->color = $subject->color !== null ? (string) $subject->color : null;
|
||||
$resource->description = $subject->description;
|
||||
$resource->status = $subject->status->value;
|
||||
$resource->createdAt = $subject->createdAt;
|
||||
$resource->updatedAt = $subject->updatedAt;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un SubjectResource à partir d'un DTO de query.
|
||||
*/
|
||||
public static function fromDto(SubjectDto $dto): self
|
||||
{
|
||||
$resource = new self();
|
||||
$resource->id = $dto->id;
|
||||
$resource->name = $dto->name;
|
||||
$resource->code = $dto->code;
|
||||
$resource->color = $dto->color;
|
||||
$resource->description = $dto->description;
|
||||
$resource->status = $dto->status;
|
||||
$resource->createdAt = $dto->createdAt;
|
||||
$resource->updatedAt = $dto->updatedAt;
|
||||
$resource->teacherCount = $dto->teacherCount;
|
||||
$resource->classCount = $dto->classCount;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,28 +28,32 @@ final readonly class DoctrineClassRepository implements ClassRepository
|
||||
#[Override]
|
||||
public function save(SchoolClass $class): void
|
||||
{
|
||||
$data = [
|
||||
'id' => (string) $class->id,
|
||||
'tenant_id' => (string) $class->tenantId,
|
||||
'school_id' => (string) $class->schoolId,
|
||||
'academic_year_id' => (string) $class->academicYearId,
|
||||
'name' => (string) $class->name,
|
||||
'level' => $class->level?->value,
|
||||
'capacity' => $class->capacity,
|
||||
'status' => $class->status->value,
|
||||
'description' => $class->description,
|
||||
'created_at' => $class->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'updated_at' => $class->updatedAt->format(DateTimeImmutable::ATOM),
|
||||
'deleted_at' => $class->deletedAt?->format(DateTimeImmutable::ATOM),
|
||||
];
|
||||
|
||||
$exists = $this->findById($class->id) !== null;
|
||||
|
||||
if ($exists) {
|
||||
$this->connection->update('school_classes', $data, ['id' => (string) $class->id]);
|
||||
} else {
|
||||
$this->connection->insert('school_classes', $data);
|
||||
}
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, capacity, status, description, created_at, updated_at, deleted_at)
|
||||
VALUES (:id, :tenant_id, :school_id, :academic_year_id, :name, :level, :capacity, :status, :description, :created_at, :updated_at, :deleted_at)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
level = EXCLUDED.level,
|
||||
capacity = EXCLUDED.capacity,
|
||||
status = EXCLUDED.status,
|
||||
description = EXCLUDED.description,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
deleted_at = EXCLUDED.deleted_at',
|
||||
[
|
||||
'id' => (string) $class->id,
|
||||
'tenant_id' => (string) $class->tenantId,
|
||||
'school_id' => (string) $class->schoolId,
|
||||
'academic_year_id' => (string) $class->academicYearId,
|
||||
'name' => (string) $class->name,
|
||||
'level' => $class->level?->value,
|
||||
'capacity' => $class->capacity,
|
||||
'status' => $class->status->value,
|
||||
'description' => $class->description,
|
||||
'created_at' => $class->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'updated_at' => $class->updatedAt->format(DateTimeImmutable::ATOM),
|
||||
'deleted_at' => $class->deletedAt?->format(DateTimeImmutable::ATOM),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\Doctrine;
|
||||
|
||||
use App\Administration\Domain\Model\AcademicYear\AcademicPeriod;
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodConfiguration;
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodType;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Repository\PeriodConfigurationRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
use function count;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Override;
|
||||
|
||||
final readonly class DoctrinePeriodConfigurationRepository implements PeriodConfigurationRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(TenantId $tenantId, AcademicYearId $academicYearId, PeriodConfiguration $configuration): void
|
||||
{
|
||||
$this->connection->transactional(function () use ($tenantId, $academicYearId, $configuration): void {
|
||||
$tenantIdStr = (string) $tenantId;
|
||||
$academicYearIdStr = (string) $academicYearId;
|
||||
$now = (new DateTimeImmutable())->format(DateTimeImmutable::ATOM);
|
||||
|
||||
$sequences = [];
|
||||
foreach ($configuration->periods as $period) {
|
||||
$sequences[] = $period->sequence;
|
||||
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO academic_periods (tenant_id, academic_year_id, period_type, sequence, label, start_date, end_date, created_at, updated_at)
|
||||
VALUES (:tenant_id, :academic_year_id, :period_type, :sequence, :label, :start_date, :end_date, :created_at, :updated_at)
|
||||
ON CONFLICT (tenant_id, academic_year_id, sequence) DO UPDATE SET
|
||||
period_type = EXCLUDED.period_type,
|
||||
label = EXCLUDED.label,
|
||||
start_date = EXCLUDED.start_date,
|
||||
end_date = EXCLUDED.end_date,
|
||||
updated_at = EXCLUDED.updated_at',
|
||||
[
|
||||
'tenant_id' => $tenantIdStr,
|
||||
'academic_year_id' => $academicYearIdStr,
|
||||
'period_type' => $configuration->type->value,
|
||||
'sequence' => $period->sequence,
|
||||
'label' => $period->label,
|
||||
'start_date' => $period->startDate->format('Y-m-d'),
|
||||
'end_date' => $period->endDate->format('Y-m-d'),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Remove any extra periods (e.g. switching from trimester to semester would leave stale rows)
|
||||
$this->connection->executeStatement(
|
||||
'DELETE FROM academic_periods
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND academic_year_id = :academic_year_id
|
||||
AND sequence > :max_sequence',
|
||||
[
|
||||
'tenant_id' => $tenantIdStr,
|
||||
'academic_year_id' => $academicYearIdStr,
|
||||
'max_sequence' => count($configuration->periods),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByAcademicYear(TenantId $tenantId, AcademicYearId $academicYearId): ?PeriodConfiguration
|
||||
{
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT * FROM academic_periods
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND academic_year_id = :academic_year_id
|
||||
ORDER BY sequence ASC',
|
||||
[
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'academic_year_id' => (string) $academicYearId,
|
||||
],
|
||||
);
|
||||
|
||||
if (count($rows) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var string $periodType */
|
||||
$periodType = $rows[0]['period_type'];
|
||||
|
||||
$periods = array_map(static function (array $row): AcademicPeriod {
|
||||
/** @var int|string $sequence */
|
||||
$sequence = $row['sequence'];
|
||||
/** @var string $label */
|
||||
$label = $row['label'];
|
||||
/** @var string $startDate */
|
||||
$startDate = $row['start_date'];
|
||||
/** @var string $endDate */
|
||||
$endDate = $row['end_date'];
|
||||
|
||||
return new AcademicPeriod(
|
||||
sequence: (int) $sequence,
|
||||
label: $label,
|
||||
startDate: new DateTimeImmutable($startDate),
|
||||
endDate: new DateTimeImmutable($endDate),
|
||||
);
|
||||
}, $rows);
|
||||
|
||||
return new PeriodConfiguration(PeriodType::from($periodType), $periods);
|
||||
}
|
||||
}
|
||||
@@ -28,27 +28,31 @@ final readonly class DoctrineSubjectRepository implements SubjectRepository
|
||||
#[Override]
|
||||
public function save(Subject $subject): void
|
||||
{
|
||||
$data = [
|
||||
'id' => (string) $subject->id,
|
||||
'tenant_id' => (string) $subject->tenantId,
|
||||
'school_id' => (string) $subject->schoolId,
|
||||
'name' => (string) $subject->name,
|
||||
'code' => (string) $subject->code,
|
||||
'color' => $subject->color !== null ? (string) $subject->color : null,
|
||||
'status' => $subject->status->value,
|
||||
'description' => $subject->description,
|
||||
'created_at' => $subject->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'updated_at' => $subject->updatedAt->format(DateTimeImmutable::ATOM),
|
||||
'deleted_at' => $subject->deletedAt?->format(DateTimeImmutable::ATOM),
|
||||
];
|
||||
|
||||
$exists = $this->findById($subject->id) !== null;
|
||||
|
||||
if ($exists) {
|
||||
$this->connection->update('subjects', $data, ['id' => (string) $subject->id]);
|
||||
} else {
|
||||
$this->connection->insert('subjects', $data);
|
||||
}
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, description, created_at, updated_at, deleted_at)
|
||||
VALUES (:id, :tenant_id, :school_id, :name, :code, :color, :status, :description, :created_at, :updated_at, :deleted_at)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
code = EXCLUDED.code,
|
||||
color = EXCLUDED.color,
|
||||
status = EXCLUDED.status,
|
||||
description = EXCLUDED.description,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
deleted_at = EXCLUDED.deleted_at',
|
||||
[
|
||||
'id' => (string) $subject->id,
|
||||
'tenant_id' => (string) $subject->tenantId,
|
||||
'school_id' => (string) $subject->schoolId,
|
||||
'name' => (string) $subject->name,
|
||||
'code' => (string) $subject->code,
|
||||
'color' => $subject->color !== null ? (string) $subject->color : null,
|
||||
'status' => $subject->status->value,
|
||||
'description' => $subject->description,
|
||||
'created_at' => $subject->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'updated_at' => $subject->updatedAt->format(DateTimeImmutable::ATOM),
|
||||
'deleted_at' => $subject->deletedAt?->format(DateTimeImmutable::ATOM),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodConfiguration;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Repository\PeriodConfigurationRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
final class InMemoryPeriodConfigurationRepository implements PeriodConfigurationRepository
|
||||
{
|
||||
/** @var array<string, PeriodConfiguration> */
|
||||
private array $configurations = [];
|
||||
|
||||
public function save(TenantId $tenantId, AcademicYearId $academicYearId, PeriodConfiguration $configuration): void
|
||||
{
|
||||
$this->configurations[$this->key($tenantId, $academicYearId)] = $configuration;
|
||||
}
|
||||
|
||||
public function findByAcademicYear(TenantId $tenantId, AcademicYearId $academicYearId): ?PeriodConfiguration
|
||||
{
|
||||
return $this->configurations[$this->key($tenantId, $academicYearId)] ?? null;
|
||||
}
|
||||
|
||||
private function key(TenantId $tenantId, AcademicYearId $academicYearId): string
|
||||
{
|
||||
return (string) $tenantId . ':' . (string) $academicYearId;
|
||||
}
|
||||
}
|
||||
@@ -72,8 +72,8 @@ final class InMemorySubjectRepository implements SubjectRepository
|
||||
): ?Subject {
|
||||
$subject = $this->byTenantSchoolCode[$this->codeKey($code, $tenantId, $schoolId)] ?? null;
|
||||
|
||||
// Filtrer les matières archivées (comme Doctrine avec deleted_at IS NULL)
|
||||
if ($subject !== null && $subject->deletedAt !== null) {
|
||||
// Filtrer les matières archivées (cohérent avec findActiveByTenantAndSchool)
|
||||
if ($subject !== null && $subject->status !== SubjectStatus::ACTIVE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Api\Resource\PeriodResource;
|
||||
|
||||
use function in_array;
|
||||
|
||||
use Override;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Voter pour les autorisations sur les périodes scolaires.
|
||||
*
|
||||
* Règles d'accès :
|
||||
* - ADMIN et SUPER_ADMIN : accès complet (lecture + configuration)
|
||||
* - Autres rôles : lecture seule
|
||||
*
|
||||
* @extends Voter<string, PeriodResource|null>
|
||||
*/
|
||||
final class PeriodVoter extends Voter
|
||||
{
|
||||
public const string VIEW = 'PERIOD_VIEW';
|
||||
public const string CONFIGURE = 'PERIOD_CONFIGURE';
|
||||
|
||||
private const array SUPPORTED_ATTRIBUTES = [
|
||||
self::VIEW,
|
||||
self::CONFIGURE,
|
||||
];
|
||||
|
||||
#[Override]
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return in_array($attribute, self::SUPPORTED_ATTRIBUTES, true);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!$user instanceof UserInterface) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roles = $user->getRoles();
|
||||
|
||||
return match ($attribute) {
|
||||
self::VIEW => $this->canView($roles),
|
||||
self::CONFIGURE => $this->canConfigure($roles),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roles
|
||||
*/
|
||||
private function canView(array $roles): bool
|
||||
{
|
||||
return $this->hasAnyRole($roles, [
|
||||
Role::SUPER_ADMIN->value,
|
||||
Role::ADMIN->value,
|
||||
Role::PROF->value,
|
||||
Role::VIE_SCOLAIRE->value,
|
||||
Role::SECRETARIAT->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roles
|
||||
*/
|
||||
private function canConfigure(array $roles): bool
|
||||
{
|
||||
return $this->hasAnyRole($roles, [
|
||||
Role::SUPER_ADMIN->value,
|
||||
Role::ADMIN->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $userRoles
|
||||
* @param string[] $allowedRoles
|
||||
*/
|
||||
private function hasAnyRole(array $userRoles, array $allowedRoles): bool
|
||||
{
|
||||
foreach ($userRoles as $role) {
|
||||
if (in_array($role, $allowedRoles, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Service;
|
||||
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
/**
|
||||
* Résout les identifiants spéciaux 'current' et 'next' en UUID v5 déterministes
|
||||
* basés sur le tenant et l'année scolaire.
|
||||
*
|
||||
* L'année scolaire est calculée selon le calendrier français :
|
||||
* septembre → juin (ex: sept 2025 = année 2025-2026).
|
||||
*/
|
||||
final readonly class CurrentAcademicYearResolver
|
||||
{
|
||||
/** Namespace UUID v5 dédié aux années scolaires. */
|
||||
private const string NAMESPACE = '6ba7b814-9dad-11d1-80b4-00c04fd430c8';
|
||||
|
||||
public function __construct(
|
||||
private TenantContext $tenantContext,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|null UUID résolu, ou null si l'identifiant est invalide
|
||||
*/
|
||||
public function resolve(string $academicYearId): ?string
|
||||
{
|
||||
if (Uuid::isValid($academicYearId)) {
|
||||
return $academicYearId;
|
||||
}
|
||||
|
||||
$startYear = $this->resolveStartYear($academicYearId);
|
||||
if ($startYear === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
$name = $tenantId . ':' . $startYear . '-' . ($startYear + 1);
|
||||
|
||||
return Uuid::uuid5(self::NAMESPACE, $name)->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout l'année de début scolaire pour un identifiant spécial.
|
||||
*
|
||||
* @return int|null L'année de début (ex: 2025 pour 2025-2026), ou null si invalide
|
||||
*/
|
||||
public function resolveStartYear(string $academicYearId): ?int
|
||||
{
|
||||
$offset = match ($academicYearId) {
|
||||
'previous' => -1,
|
||||
'current' => 0,
|
||||
'next' => 1,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($offset === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = $this->clock->now();
|
||||
$month = (int) $now->format('n');
|
||||
$year = (int) $now->format('Y');
|
||||
|
||||
return ($month >= 9 ? $year : $year - 1) + $offset;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Service;
|
||||
|
||||
use App\Administration\Application\Port\GradeExistenceChecker;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* Implémentation par défaut qui retourne toujours false.
|
||||
*
|
||||
* Sera remplacée par une implémentation réelle quand le module Notes (Epic 6) existera.
|
||||
*/
|
||||
final class NoOpGradeExistenceChecker implements GradeExistenceChecker
|
||||
{
|
||||
#[Override]
|
||||
public function hasGradesInPeriod(
|
||||
TenantId $tenantId,
|
||||
AcademicYearId $academicYearId,
|
||||
int $periodSequence,
|
||||
): bool {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user