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 e72867932d
107 changed files with 9709 additions and 383 deletions

View File

@@ -0,0 +1,487 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\ReadModel;
use App\Scolarite\Application\Port\TeacherStatisticsReader;
use Doctrine\DBAL\Connection;
use function is_numeric;
use function round;
final readonly class DbalTeacherStatisticsReader implements TeacherStatisticsReader
{
public function __construct(
private Connection $connection,
) {
}
public function teacherClassesSummary(
string $teacherId,
string $tenantId,
string $periodStartDate,
string $periodEndDate,
): array {
$rows = $this->connection->fetchAllAssociative(
<<<'SQL'
SELECT
ta.school_class_id AS class_id,
sc.name AS class_name,
ta.subject_id,
s.name AS subject_name,
COUNT(DISTINCT e.id) AS evaluation_count,
COUNT(DISTINCT g.student_id) AS student_count,
CASE WHEN COUNT(g.id) FILTER (WHERE g.status = 'graded') > 0
THEN ROUND(AVG(g.value * 20.0 / NULLIF(e.grade_scale, 0)) FILTER (WHERE g.status = 'graded'), 2)
ELSE NULL
END AS average,
CASE WHEN COUNT(g.id) FILTER (WHERE g.status = 'graded') > 0
THEN ROUND(
COUNT(g.id) FILTER (WHERE g.status = 'graded' AND g.value * 20.0 / NULLIF(e.grade_scale, 0) >= 10) * 100.0
/ COUNT(g.id) FILTER (WHERE g.status = 'graded'),
1
)
ELSE NULL
END AS success_rate
FROM teacher_assignments ta
JOIN school_classes sc ON sc.id = ta.school_class_id
JOIN subjects s ON s.id = ta.subject_id
LEFT JOIN evaluations e ON e.class_id = ta.school_class_id
AND e.subject_id = ta.subject_id
AND e.teacher_id = ta.teacher_id
AND e.tenant_id = ta.tenant_id
AND e.status != 'deleted'
AND e.grades_published_at IS NOT NULL
AND e.evaluation_date BETWEEN :period_start AND :period_end
LEFT JOIN grades g ON g.evaluation_id = e.id AND g.tenant_id = ta.tenant_id
WHERE ta.teacher_id = :teacher_id
AND ta.tenant_id = :tenant_id
AND ta.status = 'active'
GROUP BY ta.school_class_id, sc.name, ta.subject_id, s.name
ORDER BY sc.name, s.name
SQL,
[
'teacher_id' => $teacherId,
'tenant_id' => $tenantId,
'period_start' => $periodStartDate,
'period_end' => $periodEndDate,
],
);
$result = [];
foreach ($rows as $row) {
/** @var string $classId */
$classId = $row['class_id'];
/** @var string $className */
$className = $row['class_name'];
/** @var string $subjectId */
$subjectId = $row['subject_id'];
/** @var string $subjectName */
$subjectName = $row['subject_name'];
/** @var string|int $evaluationCount */
$evaluationCount = $row['evaluation_count'];
/** @var string|int $studentCount */
$studentCount = $row['student_count'];
/** @var string|float|null $average */
$average = $row['average'];
/** @var string|float|null $successRate */
$successRate = $row['success_rate'];
$result[] = [
'classId' => $classId,
'className' => $className,
'subjectId' => $subjectId,
'subjectName' => $subjectName,
'evaluationCount' => (int) $evaluationCount,
'studentCount' => (int) $studentCount,
'average' => $average !== null ? (float) $average : null,
'successRate' => $successRate !== null ? (float) $successRate : null,
];
}
return $result;
}
public function classGradesNormalized(
string $teacherId,
string $classId,
string $subjectId,
string $tenantId,
string $periodStartDate,
string $periodEndDate,
): array {
$rows = $this->connection->fetchFirstColumn(
<<<'SQL'
SELECT ROUND(g.value * 20.0 / NULLIF(e.grade_scale, 0), 2)
FROM grades g
JOIN evaluations e ON e.id = g.evaluation_id
WHERE e.teacher_id = :teacher_id
AND e.class_id = :class_id
AND e.subject_id = :subject_id
AND e.tenant_id = :tenant_id
AND e.status != 'deleted'
AND e.grades_published_at IS NOT NULL
AND e.evaluation_date BETWEEN :period_start AND :period_end
AND g.status = 'graded'
AND g.tenant_id = :tenant_id
ORDER BY g.value
SQL,
[
'teacher_id' => $teacherId,
'class_id' => $classId,
'subject_id' => $subjectId,
'tenant_id' => $tenantId,
'period_start' => $periodStartDate,
'period_end' => $periodEndDate,
],
);
$result = [];
foreach ($rows as $v) {
if (is_numeric($v)) {
$result[] = (float) $v;
}
}
return $result;
}
public function classMonthlyAverages(
string $teacherId,
string $classId,
string $subjectId,
string $tenantId,
string $academicYearStart,
string $academicYearEnd,
): array {
$rows = $this->connection->fetchAllAssociative(
<<<'SQL'
SELECT
TO_CHAR(e.evaluation_date, 'YYYY-MM') AS month,
ROUND(AVG(g.value * 20.0 / NULLIF(e.grade_scale, 0)), 2) AS average
FROM grades g
JOIN evaluations e ON e.id = g.evaluation_id
WHERE e.teacher_id = :teacher_id
AND e.class_id = :class_id
AND e.subject_id = :subject_id
AND e.tenant_id = :tenant_id
AND e.status != 'deleted'
AND e.grades_published_at IS NOT NULL
AND g.status = 'graded'
AND g.tenant_id = :tenant_id
AND e.evaluation_date BETWEEN :year_start AND :year_end
GROUP BY TO_CHAR(e.evaluation_date, 'YYYY-MM')
ORDER BY month
SQL,
[
'teacher_id' => $teacherId,
'class_id' => $classId,
'subject_id' => $subjectId,
'tenant_id' => $tenantId,
'year_start' => $academicYearStart,
'year_end' => $academicYearEnd,
],
);
$result = [];
foreach ($rows as $row) {
/** @var string $month */
$month = $row['month'];
/** @var string|float $average */
$average = $row['average'];
$result[] = [
'month' => $month,
'average' => (float) $average,
];
}
return $result;
}
/**
* Les moyennes lues depuis student_averages incluent toutes les évaluations
* de la matière (tous enseignants confondus) — c'est le même agrégat que
* celui du bulletin scolaire. Le JOIN teacher_assignments contrôle l'accès
* (seul un enseignant affecté à la classe peut appeler cette méthode).
*/
public function studentAveragesForClass(
string $teacherId,
string $classId,
string $subjectId,
string $periodId,
string $tenantId,
): array {
$rows = $this->connection->fetchAllAssociative(
<<<'SQL'
SELECT
ca.user_id AS student_id,
CONCAT(u.first_name, ' ', u.last_name) AS student_name,
sa.average
FROM class_assignments ca
JOIN users u ON u.id = ca.user_id
JOIN teacher_assignments ta ON ta.school_class_id = ca.school_class_id
AND ta.subject_id = :subject_id
AND ta.teacher_id = :teacher_id
AND ta.tenant_id = :tenant_id
AND ta.status = 'active'
LEFT JOIN student_averages sa ON sa.student_id = ca.user_id
AND sa.subject_id = :subject_id
AND sa.period_id = :period_id
AND sa.tenant_id = :tenant_id
WHERE ca.school_class_id = :class_id
AND ca.tenant_id = :tenant_id
ORDER BY u.last_name, u.first_name
SQL,
[
'teacher_id' => $teacherId,
'class_id' => $classId,
'subject_id' => $subjectId,
'period_id' => $periodId,
'tenant_id' => $tenantId,
],
);
$result = [];
foreach ($rows as $row) {
/** @var string $studentId */
$studentId = $row['student_id'];
/** @var string $studentName */
$studentName = $row['student_name'];
/** @var string|float|null $studentAvg */
$studentAvg = $row['average'];
$result[] = [
'studentId' => $studentId,
'studentName' => $studentName,
'average' => $studentAvg !== null ? (float) $studentAvg : null,
];
}
return $result;
}
public function studentMonthlyAveragesForClass(
string $classId,
string $subjectId,
string $tenantId,
string $academicYearStart,
string $academicYearEnd,
): array {
$rows = $this->connection->fetchAllAssociative(
<<<'SQL'
SELECT
g.student_id,
TO_CHAR(e.evaluation_date, 'YYYY-MM') AS month,
ROUND(AVG(g.value * 20.0 / NULLIF(e.grade_scale, 0)), 2) AS average
FROM grades g
JOIN evaluations e ON e.id = g.evaluation_id
WHERE e.class_id = :class_id
AND e.subject_id = :subject_id
AND e.tenant_id = :tenant_id
AND e.status != 'deleted'
AND e.grades_published_at IS NOT NULL
AND g.status = 'graded'
AND g.tenant_id = :tenant_id
AND e.evaluation_date BETWEEN :year_start AND :year_end
GROUP BY g.student_id, TO_CHAR(e.evaluation_date, 'YYYY-MM')
ORDER BY g.student_id, month
SQL,
[
'class_id' => $classId,
'subject_id' => $subjectId,
'tenant_id' => $tenantId,
'year_start' => $academicYearStart,
'year_end' => $academicYearEnd,
],
);
/** @var array<string, list<float>> $result */
$result = [];
foreach ($rows as $row) {
/** @var string $sid */
$sid = $row['student_id'];
/** @var string|float $avg */
$avg = $row['average'];
$result[$sid][] = (float) $avg;
}
return $result;
}
public function studentGradeHistory(
string $studentId,
string $subjectId,
string $classId,
string $teacherId,
string $tenantId,
string $academicYearStart,
string $academicYearEnd,
): array {
$rows = $this->connection->fetchAllAssociative(
<<<'SQL'
SELECT
e.evaluation_date::date AS date,
ROUND(g.value * 20.0 / NULLIF(e.grade_scale, 0), 2) AS value,
e.title AS evaluation_title
FROM grades g
JOIN evaluations e ON e.id = g.evaluation_id
JOIN teacher_assignments ta ON ta.school_class_id = e.class_id
AND ta.subject_id = e.subject_id
AND ta.teacher_id = :teacher_id
AND ta.tenant_id = :tenant_id
AND ta.status = 'active'
WHERE g.student_id = :student_id
AND e.subject_id = :subject_id
AND e.class_id = :class_id
AND e.tenant_id = :tenant_id
AND e.status != 'deleted'
AND e.grades_published_at IS NOT NULL
AND g.status = 'graded'
AND g.tenant_id = :tenant_id
AND e.evaluation_date BETWEEN :year_start AND :year_end
ORDER BY e.evaluation_date
SQL,
[
'student_id' => $studentId,
'subject_id' => $subjectId,
'class_id' => $classId,
'teacher_id' => $teacherId,
'tenant_id' => $tenantId,
'year_start' => $academicYearStart,
'year_end' => $academicYearEnd,
],
);
$result = [];
foreach ($rows as $row) {
/** @var string $date */
$date = $row['date'];
/** @var string|float $value */
$value = $row['value'];
/** @var string $evaluationTitle */
$evaluationTitle = $row['evaluation_title'];
$result[] = [
'date' => $date,
'value' => (float) $value,
'evaluationTitle' => $evaluationTitle,
];
}
return $result;
}
public function teacherEvaluationDifficulties(string $teacherId, string $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
<<<'SQL'
SELECT
e.id AS evaluation_id,
e.title,
e.class_id,
sc.name AS class_name,
e.subject_id,
s.name AS subject_name,
e.evaluation_date::date AS date,
es.average,
COALESCE(es.graded_count, 0) AS graded_count
FROM evaluations e
JOIN school_classes sc ON sc.id = e.class_id
JOIN subjects s ON s.id = e.subject_id
LEFT JOIN evaluation_statistics es ON es.evaluation_id = e.id
WHERE e.teacher_id = :teacher_id
AND e.tenant_id = :tenant_id
AND e.status != 'deleted'
AND e.grades_published_at IS NOT NULL
ORDER BY e.evaluation_date DESC
SQL,
[
'teacher_id' => $teacherId,
'tenant_id' => $tenantId,
],
);
$result = [];
foreach ($rows as $row) {
/** @var string $evaluationId */
$evaluationId = $row['evaluation_id'];
/** @var string $title */
$title = $row['title'];
/** @var string $rowClassId */
$rowClassId = $row['class_id'];
/** @var string $className */
$className = $row['class_name'];
/** @var string $rowSubjectId */
$rowSubjectId = $row['subject_id'];
/** @var string $subjectName */
$subjectName = $row['subject_name'];
/** @var string $date */
$date = $row['date'];
/** @var string|int $gradedCount */
$gradedCount = $row['graded_count'];
/** @var string|float|null $evalAvg */
$evalAvg = $row['average'];
$result[] = [
'evaluationId' => $evaluationId,
'title' => $title,
'classId' => $rowClassId,
'className' => $className,
'subjectId' => $rowSubjectId,
'subjectName' => $subjectName,
'date' => $date,
'average' => $evalAvg !== null ? round((float) $evalAvg, 2) : null,
'gradedCount' => (int) $gradedCount,
];
}
return $result;
}
public function subjectAveragesForOtherTeachers(
string $teacherId,
string $subjectId,
string $tenantId,
): array {
$rows = $this->connection->fetchFirstColumn(
<<<'SQL'
SELECT ROUND(AVG(es.average), 2)
FROM evaluations e
JOIN evaluation_statistics es ON es.evaluation_id = e.id
WHERE e.subject_id = :subject_id
AND e.tenant_id = :tenant_id
AND e.teacher_id != :teacher_id
AND e.status != 'deleted'
AND e.grades_published_at IS NOT NULL
AND es.average IS NOT NULL
GROUP BY e.teacher_id
SQL,
[
'teacher_id' => $teacherId,
'subject_id' => $subjectId,
'tenant_id' => $tenantId,
],
);
$result = [];
foreach ($rows as $v) {
if (is_numeric($v)) {
$result[] = (float) $v;
}
}
return $result;
}
}