feat: Calculer automatiquement les moyennes après chaque saisie de notes
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

Les enseignants ont besoin de moyennes à jour immédiatement après la
publication ou modification des notes, sans attendre un batch nocturne.

Le système recalcule via Domain Events synchrones : statistiques
d'évaluation (min/max/moyenne/médiane), moyennes matières pondérées
(normalisation /20), et moyenne générale par élève. Les résultats sont
stockés dans des tables dénormalisées avec cache Redis (TTL 5 min).

Trois endpoints API exposent les données avec contrôle d'accès par rôle.
Une commande console permet le backfill des données historiques au
déploiement.
This commit is contained in:
2026-03-30 06:22:03 +02:00
parent b70d5ec2ad
commit e745cf326a
733 changed files with 113156 additions and 286 deletions

View File

@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Repository\StudentAverageRepository;
use App\Shared\Domain\Tenant\TenantId;
use Doctrine\DBAL\Connection;
use Override;
final readonly class DoctrineStudentAverageRepository implements StudentAverageRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function saveSubjectAverage(
TenantId $tenantId,
UserId $studentId,
SubjectId $subjectId,
string $periodId,
float $average,
int $gradeCount,
): void {
$this->connection->executeStatement(
'INSERT INTO student_averages (id, tenant_id, student_id, subject_id, period_id, average, grade_count, updated_at)
VALUES (gen_random_uuid(), :tenant_id, :student_id, :subject_id, :period_id, :average, :grade_count, NOW())
ON CONFLICT (student_id, subject_id, period_id) DO UPDATE SET
average = EXCLUDED.average,
grade_count = EXCLUDED.grade_count,
updated_at = NOW()',
[
'tenant_id' => (string) $tenantId,
'student_id' => (string) $studentId,
'subject_id' => (string) $subjectId,
'period_id' => $periodId,
'average' => $average,
'grade_count' => $gradeCount,
],
);
}
#[Override]
public function saveGeneralAverage(
TenantId $tenantId,
UserId $studentId,
string $periodId,
float $average,
): void {
$this->connection->executeStatement(
'INSERT INTO student_general_averages (id, tenant_id, student_id, period_id, average, updated_at)
VALUES (gen_random_uuid(), :tenant_id, :student_id, :period_id, :average, NOW())
ON CONFLICT (student_id, period_id) DO UPDATE SET
average = EXCLUDED.average,
updated_at = NOW()',
[
'tenant_id' => (string) $tenantId,
'student_id' => (string) $studentId,
'period_id' => $periodId,
'average' => $average,
],
);
}
#[Override]
public function findSubjectAveragesForStudent(
UserId $studentId,
string $periodId,
TenantId $tenantId,
): array {
$rows = $this->connection->fetchAllAssociative(
'SELECT average FROM student_averages
WHERE student_id = :student_id
AND period_id = :period_id
AND tenant_id = :tenant_id',
[
'student_id' => (string) $studentId,
'period_id' => $periodId,
'tenant_id' => (string) $tenantId,
],
);
return array_map(
/** @param array<string, mixed> $row */
static function (array $row): float {
/** @var string|float $avg */
$avg = $row['average'];
return (float) $avg;
},
$rows,
);
}
#[Override]
public function findDetailedSubjectAveragesForStudent(
UserId $studentId,
string $periodId,
TenantId $tenantId,
): array {
$rows = $this->connection->fetchAllAssociative(
'SELECT sa.subject_id, sa.average, sa.grade_count, s.name as subject_name
FROM student_averages sa
LEFT JOIN subjects s ON s.id = sa.subject_id
WHERE sa.student_id = :student_id
AND sa.period_id = :period_id
AND sa.tenant_id = :tenant_id
ORDER BY s.name ASC',
[
'student_id' => (string) $studentId,
'period_id' => $periodId,
'tenant_id' => (string) $tenantId,
],
);
return array_map(
/** @param array<string, mixed> $row */
static function (array $row): array {
/** @var string $subjectId */
$subjectId = $row['subject_id'];
/** @var string|null $subjectName */
$subjectName = $row['subject_name'];
/** @var string|float $average */
$average = $row['average'];
/** @var string|int $gradeCount */
$gradeCount = $row['grade_count'];
return [
'subjectId' => $subjectId,
'subjectName' => $subjectName,
'average' => (float) $average,
'gradeCount' => (int) $gradeCount,
];
},
$rows,
);
}
#[Override]
public function findGeneralAverageForStudent(
UserId $studentId,
string $periodId,
TenantId $tenantId,
): ?float {
$row = $this->connection->fetchAssociative(
'SELECT average FROM student_general_averages
WHERE student_id = :student_id
AND period_id = :period_id
AND tenant_id = :tenant_id',
[
'student_id' => (string) $studentId,
'period_id' => $periodId,
'tenant_id' => (string) $tenantId,
],
);
if ($row === false) {
return null;
}
/** @var string|float $average */
$average = $row['average'];
return (float) $average;
}
#[Override]
public function deleteSubjectAverage(
UserId $studentId,
SubjectId $subjectId,
string $periodId,
TenantId $tenantId,
): void {
$this->connection->executeStatement(
'DELETE FROM student_averages
WHERE student_id = :student_id
AND subject_id = :subject_id
AND period_id = :period_id
AND tenant_id = :tenant_id',
[
'student_id' => (string) $studentId,
'subject_id' => (string) $subjectId,
'period_id' => $periodId,
'tenant_id' => (string) $tenantId,
],
);
}
#[Override]
public function deleteGeneralAverage(
UserId $studentId,
string $periodId,
TenantId $tenantId,
): void {
$this->connection->executeStatement(
'DELETE FROM student_general_averages
WHERE student_id = :student_id
AND period_id = :period_id
AND tenant_id = :tenant_id',
[
'student_id' => (string) $studentId,
'period_id' => $periodId,
'tenant_id' => (string) $tenantId,
],
);
}
}