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:
2026-02-06 12:00:29 +01:00
parent 0d5a097c4c
commit f19d0ae3ef
69 changed files with 5201 additions and 121 deletions

View File

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

View File

@@ -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) {

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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