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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user