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 aedde6707e
694 changed files with 109792 additions and 75 deletions

View File

@@ -0,0 +1,394 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Scolarite\Api;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
use App\Scolarite\Domain\Model\Grade\Grade;
use App\Scolarite\Domain\Model\Grade\GradeStatus;
use App\Scolarite\Domain\Model\Grade\GradeValue;
use App\Scolarite\Domain\Repository\EvaluationRepository;
use App\Scolarite\Domain\Repository\EvaluationStatisticsRepository;
use App\Scolarite\Domain\Repository\GradeRepository;
use App\Scolarite\Domain\Repository\StudentAverageRepository;
use App\Scolarite\Domain\Service\AverageCalculator;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\Test;
final class MoyennesEndpointsTest extends ApiTestCase
{
protected static ?bool $alwaysBootKernel = true;
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
private const string TEACHER_ID = '44444444-4444-4444-4444-444444444444';
private const string OTHER_TEACHER_ID = '44444444-4444-4444-4444-444444444445';
private const string STUDENT_ID = '22222222-2222-2222-2222-222222222222';
private const string CLASS_ID = '55555555-5555-5555-5555-555555555555';
private const string SUBJECT_ID = '66666666-6666-6666-6666-666666666666';
private const string PERIOD_ID = '11111111-1111-1111-1111-111111111111';
private const string BASE_URL = 'http://ecole-alpha.classeo.local/api';
private ?EvaluationId $evaluationId = null;
protected function setUp(): void
{
parent::setUp();
$this->seedFixtures();
}
protected function tearDown(): void
{
/** @var Connection $connection */
$connection = static::getContainer()->get(Connection::class);
$connection->executeStatement('DELETE FROM evaluation_statistics WHERE evaluation_id IN (SELECT id FROM evaluations WHERE tenant_id = :tid)', ['tid' => self::TENANT_ID]);
$connection->executeStatement('DELETE FROM student_general_averages WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]);
$connection->executeStatement('DELETE FROM student_averages WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]);
$connection->executeStatement('DELETE FROM grades WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]);
$connection->executeStatement('DELETE FROM evaluations WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]);
parent::tearDown();
}
// =========================================================================
// GET /evaluations/{id}/statistics — Auth
// =========================================================================
#[Test]
public function getEvaluationStatisticsReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('GET', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/statistics', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function getEvaluationStatisticsReturns403ForNonOwner(): void
{
$client = $this->createAuthenticatedClient(self::OTHER_TEACHER_ID, ['ROLE_PROF']);
$client->request('GET', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/statistics', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(403);
}
// =========================================================================
// GET /evaluations/{id}/statistics — Happy path
// =========================================================================
#[Test]
public function getEvaluationStatisticsReturnsStatsForOwner(): void
{
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
$client->request('GET', self::BASE_URL . '/evaluations/' . $this->evaluationId . '/statistics', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
self::assertJsonContains([
'evaluationId' => (string) $this->evaluationId,
'gradedCount' => 2,
]);
}
#[Test]
public function getEvaluationStatisticsReturns404ForUnknownEvaluation(): void
{
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
$unknownId = (string) EvaluationId::generate();
$client->request('GET', self::BASE_URL . '/evaluations/' . $unknownId . '/statistics', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(404);
}
// =========================================================================
// GET /students/{id}/averages — Auth
// =========================================================================
#[Test]
public function getStudentAveragesReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('GET', self::BASE_URL . '/students/' . self::STUDENT_ID . '/averages?periodId=' . self::PERIOD_ID, [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function getStudentAveragesReturns403ForUnrelatedParent(): void
{
$parentId = '88888888-8888-8888-8888-888888888888';
$client = $this->createAuthenticatedClient($parentId, ['ROLE_PARENT']);
$client->request('GET', self::BASE_URL . '/students/' . self::STUDENT_ID . '/averages?periodId=' . self::PERIOD_ID, [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(403);
}
// =========================================================================
// GET /students/{id}/averages — Happy path
// =========================================================================
#[Test]
public function getStudentAveragesReturnsDataForStaff(): void
{
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
$client->request('GET', self::BASE_URL . '/students/' . self::STUDENT_ID . '/averages?periodId=' . self::PERIOD_ID, [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
self::assertJsonContains([
'studentId' => self::STUDENT_ID,
'periodId' => self::PERIOD_ID,
]);
}
#[Test]
public function getStudentAveragesReturnsOwnDataForStudent(): void
{
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
$client->request('GET', self::BASE_URL . '/students/' . self::STUDENT_ID . '/averages?periodId=' . self::PERIOD_ID, [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
self::assertJsonContains([
'studentId' => self::STUDENT_ID,
]);
}
// =========================================================================
// GET /classes/{id}/statistics — Auth
// =========================================================================
#[Test]
public function getClassStatisticsReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('GET', self::BASE_URL . '/classes/' . self::CLASS_ID . '/statistics', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(401);
}
#[Test]
public function getClassStatisticsReturns403ForStudent(): void
{
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
$client->request('GET', self::BASE_URL . '/classes/' . self::CLASS_ID . '/statistics', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(403);
}
#[Test]
public function getClassStatisticsReturns403ForParent(): void
{
$parentId = '88888888-8888-8888-8888-888888888888';
$client = $this->createAuthenticatedClient($parentId, ['ROLE_PARENT']);
$client->request('GET', self::BASE_URL . '/classes/' . self::CLASS_ID . '/statistics', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseStatusCodeSame(403);
}
// =========================================================================
// GET /classes/{id}/statistics — Happy path
// =========================================================================
#[Test]
public function getClassStatisticsReturnsDataForTeacher(): void
{
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
$client->request('GET', self::BASE_URL . '/classes/' . self::CLASS_ID . '/statistics', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
self::assertJsonContains([
'classId' => self::CLASS_ID,
]);
}
#[Test]
public function getClassStatisticsReturnsDataForAdmin(): void
{
$adminId = '99999999-9999-9999-9999-999999999999';
$client = $this->createAuthenticatedClient($adminId, ['ROLE_ADMIN']);
$client->request('GET', self::BASE_URL . '/classes/' . self::CLASS_ID . '/statistics', [
'headers' => ['Accept' => 'application/json'],
]);
self::assertResponseIsSuccessful();
}
// =========================================================================
// Helpers
// =========================================================================
/**
* @param list<string> $roles
*/
private function createAuthenticatedClient(string $userId, array $roles): \ApiPlatform\Symfony\Bundle\Test\Client
{
$client = static::createClient();
$user = new SecurityUser(
userId: UserId::fromString($userId),
email: 'test@classeo.local',
hashedPassword: '',
tenantId: TenantId::fromString(self::TENANT_ID),
roles: $roles,
);
$client->loginUser($user, 'api');
return $client;
}
private function seedFixtures(): void
{
$container = static::getContainer();
/** @var Connection $connection */
$connection = $container->get(Connection::class);
$tenantId = TenantId::fromString(self::TENANT_ID);
$now = new DateTimeImmutable();
$schoolId = '550e8400-e29b-41d4-a716-ff6655440001';
$academicYearId = '550e8400-e29b-41d4-a716-ff6655440002';
// Seed parent tables
$connection->executeStatement(
"INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at)
VALUES (:id, :tid, 'teacher-moy@test.local', '', 'Test', 'Teacher', '[\"ROLE_PROF\"]', 'active', NOW(), NOW())
ON CONFLICT (id) DO NOTHING",
['id' => self::TEACHER_ID, 'tid' => self::TENANT_ID],
);
$connection->executeStatement(
"INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at)
VALUES (:id, :tid, 'student-moy@test.local', '', 'Test', 'Student', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW())
ON CONFLICT (id) DO NOTHING",
['id' => self::STUDENT_ID, 'tid' => self::TENANT_ID],
);
$connection->executeStatement(
"INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at)
VALUES (:id, :tid, 'student2-moy@test.local', '', 'Test', 'Student2', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW())
ON CONFLICT (id) DO NOTHING",
['id' => '33333333-3333-3333-3333-333333333333', 'tid' => self::TENANT_ID],
);
$connection->executeStatement(
"INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, status, created_at, updated_at)
VALUES (:id, :tid, :sid, :ayid, 'Test-Moy-Class', 'active', NOW(), NOW())
ON CONFLICT (id) DO NOTHING",
['id' => self::CLASS_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId, 'ayid' => $academicYearId],
);
$connection->executeStatement(
"INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at)
VALUES (:id, :tid, :sid, 'Test-Moy-Subject', 'TMOY', 'active', NOW(), NOW())
ON CONFLICT (id) DO NOTHING",
['id' => self::SUBJECT_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId],
);
$connection->executeStatement(
"INSERT INTO academic_periods (id, tenant_id, academic_year_id, period_type, sequence, label, start_date, end_date)
VALUES (:id, :tid, :ayid, 'trimester', 2, 'Trimestre 2', '2026-01-01', '2026-03-31')
ON CONFLICT (id) DO NOTHING",
['id' => self::PERIOD_ID, 'tid' => self::TENANT_ID, 'ayid' => $academicYearId],
);
// Créer une évaluation publiée avec 2 notes
$evaluation = Evaluation::creer(
tenantId: $tenantId,
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString(self::SUBJECT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
title: 'DS Mathématiques',
description: null,
evaluationDate: new DateTimeImmutable('2026-02-15'),
gradeScale: new GradeScale(20),
coefficient: new Coefficient(1.0),
now: $now,
);
$evaluation->publierNotes($now);
$evaluation->pullDomainEvents();
/** @var EvaluationRepository $evalRepo */
$evalRepo = $container->get(EvaluationRepository::class);
$evalRepo->save($evaluation);
$this->evaluationId = $evaluation->id;
/** @var GradeRepository $gradeRepo */
$gradeRepo = $container->get(GradeRepository::class);
$student2Id = '33333333-3333-3333-3333-333333333333';
foreach ([
[self::STUDENT_ID, 16.0],
[$student2Id, 12.0],
] as [$studentId, $value]) {
$grade = Grade::saisir(
tenantId: $tenantId,
evaluationId: $evaluation->id,
studentId: UserId::fromString($studentId),
value: new GradeValue($value),
status: GradeStatus::GRADED,
gradeScale: new GradeScale(20),
createdBy: UserId::fromString(self::TEACHER_ID),
now: $now,
);
$grade->pullDomainEvents();
$gradeRepo->save($grade);
}
// Calculer et sauvegarder les statistiques
/** @var AverageCalculator $calculator */
$calculator = $container->get(AverageCalculator::class);
$stats = $calculator->calculateClassStatistics([16.0, 12.0]);
/** @var EvaluationStatisticsRepository $statsRepo */
$statsRepo = $container->get(EvaluationStatisticsRepository::class);
$statsRepo->save($evaluation->id, $stats);
// Sauvegarder une moyenne élève
/** @var StudentAverageRepository $avgRepo */
$avgRepo = $container->get(StudentAverageRepository::class);
$avgRepo->saveSubjectAverage(
$tenantId,
UserId::fromString(self::STUDENT_ID),
SubjectId::fromString(self::SUBJECT_ID),
self::PERIOD_ID,
16.0,
1,
);
$avgRepo->saveGeneralAverage(
$tenantId,
UserId::fromString(self::STUDENT_ID),
self::PERIOD_ID,
16.0,
);
}
}