feat: Provisionner automatiquement un nouvel établissement
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

Lorsqu'un super-admin crée un établissement via l'interface, le système
doit automatiquement créer la base tenant, exécuter les migrations,
créer le premier utilisateur admin et envoyer l'invitation — le tout
de manière asynchrone pour ne pas bloquer la réponse HTTP.

Ce mécanisme rend chaque établissement opérationnel dès sa création
sans intervention manuelle sur l'infrastructure.
This commit is contained in:
2026-04-08 13:55:41 +02:00
parent bec211ebf0
commit 3575d095a1
106 changed files with 9586 additions and 380 deletions

View File

@@ -6,22 +6,26 @@ namespace App\Scolarite\Application\Query\GetBlockedDates;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Scolarite\Application\Port\HomeworkRulesChecker;
use App\Shared\Domain\Clock;
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.
* Retourne les dates bloquées (jours fériés, vacances, journées pédagogiques, weekends,
* et dates non conformes aux règles de devoirs) pour une plage de dates donnée.
*
* Utilisé par le frontend pour griser les jours non modifiables dans la grille EDT.
* Utilisé par le frontend pour griser les jours non disponibles dans le calendrier.
*/
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetBlockedDatesHandler
{
public function __construct(
private SchoolCalendarRepository $calendarRepository,
private HomeworkRulesChecker $rulesChecker,
private Clock $clock,
) {
}
@@ -37,6 +41,7 @@ final readonly class GetBlockedDatesHandler
$endDate = new DateTimeImmutable($query->endDate);
$oneDay = new DateInterval('P1D');
$now = $this->clock->now();
$blockedDates = [];
$current = $startDate;
@@ -50,14 +55,21 @@ final readonly class GetBlockedDatesHandler
reason: $dayOfWeek === 6 ? 'Samedi' : 'Dimanche',
type: 'weekend',
);
} elseif ($calendar !== null) {
$entry = $calendar->trouverEntreePourDate($current);
} elseif ($calendar !== null && ($entry = $calendar->trouverEntreePourDate($current)) !== null) {
$blockedDates[] = new BlockedDateDto(
date: $dateStr,
reason: $entry->label,
type: $entry->type->value,
);
} else {
$dueDate = new DateTimeImmutable($dateStr);
$result = $this->rulesChecker->verifier($tenantId, $dueDate, $now);
if ($entry !== null) {
if (!$result->estValide()) {
$blockedDates[] = new BlockedDateDto(
date: $dateStr,
reason: $entry->label,
type: $entry->type->value,
reason: $result->messages()[0] ?? 'Règle de devoirs',
type: $result->estBloquant() ? 'rule_hard' : 'rule_soft',
);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetClassStatisticsDetail;
final readonly class ClassStatisticsDetailDto
{
/**
* @param list<int> $distribution
* @param list<array{month: string, average: float}> $evolution
* @param list<StudentAverageDto> $students
*/
public function __construct(
public ?float $average,
public float $successRate,
public array $distribution,
public array $evolution,
public array $students,
) {
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetClassStatisticsDetail;
use App\Scolarite\Application\Port\PeriodFinder;
use App\Scolarite\Application\Port\TeacherStatisticsReader;
use App\Scolarite\Domain\Service\AverageCalculator;
use App\Scolarite\Domain\Service\TeacherStatisticsCalculator;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use function count;
use DateTimeImmutable;
use function sprintf;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetClassStatisticsDetailHandler
{
public function __construct(
private TeacherStatisticsReader $reader,
private PeriodFinder $periodFinder,
private TeacherStatisticsCalculator $statisticsCalculator,
private AverageCalculator $averageCalculator,
) {
}
public function __invoke(GetClassStatisticsDetailQuery $query): ClassStatisticsDetailDto
{
$tenantId = TenantId::fromString($query->tenantId);
$period = $this->periodFinder->findForDate(new DateTimeImmutable(), $tenantId);
if ($period === null) {
return new ClassStatisticsDetailDto(
average: null,
successRate: 0.0,
distribution: [0, 0, 0, 0, 0, 0, 0, 0],
evolution: [],
students: [],
);
}
$normalizedGrades = $this->reader->classGradesNormalized(
$query->teacherId,
$query->classId,
$query->subjectId,
$query->tenantId,
$period->startDate->format('Y-m-d'),
$period->endDate->format('Y-m-d'),
);
$classStats = $this->averageCalculator->calculateClassStatistics($normalizedGrades);
$distribution = $this->statisticsCalculator->calculateDistribution($normalizedGrades);
$successRate = $this->statisticsCalculator->calculateSuccessRate($normalizedGrades);
$now = new DateTimeImmutable();
$month = (int) $now->format('n');
$yearStart = $month >= 9 ? (int) $now->format('Y') : (int) $now->format('Y') - 1;
$academicYearStart = sprintf('%d-09-01', $yearStart);
$academicYearEnd = sprintf('%d-08-31', $yearStart + 1);
$evolution = $this->reader->classMonthlyAverages(
$query->teacherId,
$query->classId,
$query->subjectId,
$query->tenantId,
$academicYearStart,
$academicYearEnd,
);
$studentAverages = $this->reader->studentAveragesForClass(
$query->teacherId,
$query->classId,
$query->subjectId,
$period->periodId,
$query->tenantId,
);
$studentTrends = $this->reader->studentMonthlyAveragesForClass(
$query->classId,
$query->subjectId,
$query->tenantId,
$academicYearStart,
$academicYearEnd,
);
$students = array_map(
fn (array $s) => new StudentAverageDto(
studentId: $s['studentId'],
studentName: $s['studentName'],
average: $s['average'],
inDifficulty: $s['average'] !== null && $s['average'] < $query->threshold,
trend: isset($studentTrends[$s['studentId']]) && count($studentTrends[$s['studentId']]) >= 2
? $this->statisticsCalculator->detectTrend($studentTrends[$s['studentId']])
: 'stable',
),
$studentAverages,
);
return new ClassStatisticsDetailDto(
average: $classStats->average,
successRate: $successRate,
distribution: $distribution,
evolution: $evolution,
students: $students,
);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetClassStatisticsDetail;
final readonly class GetClassStatisticsDetailQuery
{
public function __construct(
public string $teacherId,
public string $classId,
public string $subjectId,
public string $tenantId,
public float $threshold = 8.0,
) {
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetClassStatisticsDetail;
final readonly class StudentAverageDto
{
public function __construct(
public string $studentId,
public string $studentName,
public ?float $average,
public bool $inDifficulty,
public string $trend,
) {
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetEvaluationDifficulty;
final readonly class EvaluationDifficultyDto
{
public function __construct(
public string $evaluationId,
public string $title,
public string $classId,
public string $className,
public string $subjectId,
public string $subjectName,
public string $date,
public ?float $average,
public int $gradedCount,
public ?float $subjectAverage,
public ?float $percentile,
) {
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetEvaluationDifficulty;
use App\Scolarite\Application\Port\TeacherStatisticsReader;
use App\Scolarite\Domain\Service\TeacherStatisticsCalculator;
use function array_sum;
use function count;
use function round;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetEvaluationDifficultyHandler
{
public function __construct(
private TeacherStatisticsReader $reader,
private TeacherStatisticsCalculator $calculator,
) {
}
/** @return list<EvaluationDifficultyDto> */
public function __invoke(GetEvaluationDifficultyQuery $query): array
{
$evaluations = $this->reader->teacherEvaluationDifficulties(
$query->teacherId,
$query->tenantId,
);
/** @var array<string, list<float>> $otherAveragesCache */
$otherAveragesCache = [];
$results = [];
foreach ($evaluations as $eval) {
/** @var string $subjectId */
$subjectId = $eval['subjectId'];
if (!isset($otherAveragesCache[$subjectId])) {
$otherAveragesCache[$subjectId] = $this->reader->subjectAveragesForOtherTeachers(
$query->teacherId,
$subjectId,
$query->tenantId,
);
}
$otherAvgs = $otherAveragesCache[$subjectId];
$subjectAverage = $otherAvgs !== [] ? round(array_sum($otherAvgs) / count($otherAvgs), 2) : null;
$percentile = $eval['average'] !== null && $otherAvgs !== []
? $this->calculator->calculatePercentile($eval['average'], $otherAvgs)
: null;
$results[] = new EvaluationDifficultyDto(
evaluationId: $eval['evaluationId'],
title: $eval['title'],
classId: $eval['classId'],
className: $eval['className'],
subjectId: $subjectId,
subjectName: $eval['subjectName'],
date: $eval['date'],
average: $eval['average'],
gradedCount: $eval['gradedCount'],
subjectAverage: $subjectAverage,
percentile: $percentile,
);
}
return $results;
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetEvaluationDifficulty;
final readonly class GetEvaluationDifficultyQuery
{
public function __construct(
public string $teacherId,
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetStudentProgression;
use App\Scolarite\Application\Port\TeacherStatisticsReader;
use App\Scolarite\Domain\Service\TeacherStatisticsCalculator;
use function array_map;
use function array_values;
use DateTimeImmutable;
use function sprintf;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetStudentProgressionHandler
{
public function __construct(
private TeacherStatisticsReader $reader,
private TeacherStatisticsCalculator $calculator,
) {
}
public function __invoke(GetStudentProgressionQuery $query): StudentProgressionDto
{
$now = new DateTimeImmutable();
$month = (int) $now->format('n');
$yearStart = $month >= 9 ? (int) $now->format('Y') : (int) $now->format('Y') - 1;
$academicYearStart = sprintf('%d-09-01', $yearStart);
$academicYearEnd = sprintf('%d-08-31', $yearStart + 1);
$history = $this->reader->studentGradeHistory(
$query->studentId,
$query->subjectId,
$query->classId,
$query->teacherId,
$query->tenantId,
$academicYearStart,
$academicYearEnd,
);
$grades = array_map(
static fn (array $row) => new GradePointDto(
date: $row['date'],
value: $row['value'],
evaluationTitle: $row['evaluationTitle'],
),
$history,
);
$points = array_values(array_map(
static fn (int $i, GradePointDto $g) => [$i + 1, $g->value],
array_keys($grades),
$grades,
));
$trendLine = $this->calculator->calculateTrendLine($points);
return new StudentProgressionDto(
grades: $grades,
trendLine: $trendLine,
);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetStudentProgression;
final readonly class GetStudentProgressionQuery
{
public function __construct(
public string $studentId,
public string $subjectId,
public string $classId,
public string $teacherId,
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetStudentProgression;
final readonly class GradePointDto
{
public function __construct(
public string $date,
public float $value,
public string $evaluationTitle,
) {
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetStudentProgression;
use App\Scolarite\Domain\Service\TrendResult;
final readonly class StudentProgressionDto
{
/**
* @param list<GradePointDto> $grades
*/
public function __construct(
public array $grades,
public ?TrendResult $trendLine,
) {
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetTeacherStatisticsOverview;
final readonly class ClassOverviewDto
{
public function __construct(
public string $classId,
public string $className,
public string $subjectId,
public string $subjectName,
public int $evaluationCount,
public int $studentCount,
public ?float $average,
public ?float $successRate,
) {
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetTeacherStatisticsOverview;
use App\Scolarite\Application\Port\PeriodFinder;
use App\Scolarite\Application\Port\TeacherStatisticsReader;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use DateTimeImmutable;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetTeacherStatisticsOverviewHandler
{
public function __construct(
private TeacherStatisticsReader $reader,
private PeriodFinder $periodFinder,
) {
}
/** @return list<ClassOverviewDto> */
public function __invoke(GetTeacherStatisticsOverviewQuery $query): array
{
$period = $this->periodFinder->findForDate(
new DateTimeImmutable(),
TenantId::fromString($query->tenantId),
);
if ($period === null) {
return [];
}
$rows = $this->reader->teacherClassesSummary(
$query->teacherId,
$query->tenantId,
$period->startDate->format('Y-m-d'),
$period->endDate->format('Y-m-d'),
);
return array_map(
static fn (array $row) => new ClassOverviewDto(
classId: $row['classId'],
className: $row['className'],
subjectId: $row['subjectId'],
subjectName: $row['subjectName'],
evaluationCount: $row['evaluationCount'],
studentCount: $row['studentCount'],
average: $row['average'],
successRate: $row['successRate'],
),
$rows,
);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetTeacherStatisticsOverview;
final readonly class GetTeacherStatisticsOverviewQuery
{
public function __construct(
public string $teacherId,
public string $tenantId,
) {
}
}