Files
Classeo/_bmad-output/implementation-artifacts/6-3-calcul-automatique-des-moyennes.md
Mathias STRASSER b7dc27f2a5
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
feat: Calculer automatiquement les moyennes après chaque saisie de notes
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.
2026-04-04 02:25:00 +02:00

11 KiB
Raw Permalink Blame History

Story 6.3 : Calcul Automatique des Moyennes

Status: done


Story

En tant que système, Je veux calculer automatiquement les moyennes par matière et générales, Afin de fournir des données actualisées instantanément après chaque saisie (FR13).


Critères d'Acceptation

AC1 : Recalcul après publication

  • Note publiée → moyenne matière élève recalculée
  • Moyenne générale élève recalculée
  • Moyenne de classe pour l'évaluation recalculée
  • Calcul < 10ms (NFR-P9)

AC2 : Calcul moyenne matière

  • Formule : Σ(note × coef) / Σ(coef)
  • Coefficients de chaque évaluation appliqués
  • Notes "absent" et "dispensé" exclues

AC3 : Calcul moyenne générale

  • Coefficients matières appliqués (si configurés)
  • Si pas de coef matière : moyenne arithmétique des moyennes matières
  • Matières sans note exclues

AC4 : Statistiques classe

  • Moyenne de classe : min / max / moyenne / médiane
  • Absents et dispensés exclus

AC5 : Recalcul sur modification

  • Note modifiée → moyennes impactées recalculées immédiatement
  • Via Domain Events (eager calculation)

AC6 : Mode compétences

  • Établissement avec mode compétences → seules notes chiffrées prises en compte
  • Compétences ont leur propre agrégation

Tâches / Sous-tâches

T1 : Service calcul moyennes (AC: #1-4)

  • T1.1 : Service AverageCalculator
  • T1.2 : Calcul moyenne matière avec coefficients
  • T1.3 : Calcul moyenne générale
  • T1.4 : Calcul statistiques classe
  • T1.5 : Tests unitaires

T2 : Domain Events triggers (AC: #5)

  • T2.1 : Listener OnNotePubliee → recalcul
  • T2.2 : Listener OnNoteModifiee → recalcul
  • T2.3 : Tests intégration

T3 : Cache Redis (AC: #1)

  • T3.1 : Cache moyennes fréquemment consultées
  • T3.2 : Invalidation sur modification
  • T3.3 : TTL configurable
  • T3.4 : Tests

T4 : Stockage moyennes (AC: #1-4)

  • T4.1 : Table student_averages dénormalisée
  • T4.2 : Table class_statistics dénormalisée
  • T4.3 : Mise à jour via events
  • T4.4 : Tests intégration

T5 : API Endpoints (AC: #1-4)

  • T5.1 : GET /api/students/{id}/averages - Moyennes élève
  • T5.2 : GET /api/classes/{id}/statistics - Stats classe
  • T5.3 : GET /api/evaluations/{id}/statistics - Stats évaluation

Notes Développeur

Dépendances

  • Story 6.1 : Création d'évaluation
  • Story 6.2 : Saisie notes

Schema Base de Données

-- Moyennes dénormalisées pour performance
CREATE TABLE student_averages (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL,
    student_id UUID NOT NULL REFERENCES users(id),
    subject_id UUID NOT NULL REFERENCES subjects(id),
    period_id UUID NOT NULL REFERENCES academic_periods(id),
    average DECIMAL(4,2),
    grade_count INT DEFAULT 0,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (student_id, subject_id, period_id)
);

CREATE TABLE student_general_averages (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL,
    student_id UUID NOT NULL REFERENCES users(id),
    period_id UUID NOT NULL REFERENCES academic_periods(id),
    average DECIMAL(4,2),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (student_id, period_id)
);

CREATE TABLE evaluation_statistics (
    evaluation_id UUID PRIMARY KEY REFERENCES evaluations(id),
    average DECIMAL(4,2),
    min_grade DECIMAL(4,2),
    max_grade DECIMAL(4,2),
    median_grade DECIMAL(4,2),
    graded_count INT DEFAULT 0,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_student_averages_student ON student_averages(student_id);
CREATE INDEX idx_student_averages_subject ON student_averages(subject_id);

Service Calcul Moyennes

final class AverageCalculator
{
    public function calculateSubjectAverage(
        UserId $studentId,
        SubjectId $subjectId,
        PeriodId $periodId
    ): ?float {
        $grades = $this->gradeRepository->findPublishedForStudentSubjectPeriod(
            $studentId,
            $subjectId,
            $periodId
        );

        $validGrades = array_filter(
            $grades,
            fn(Grade $g) => $g->status === GradeStatus::GRADED
        );

        if (empty($validGrades)) {
            return null;
        }

        $sumWeighted = 0;
        $sumCoef = 0;

        foreach ($validGrades as $grade) {
            $normalizedValue = $grade->evaluation->gradeScale->convertTo20($grade->value);
            $sumWeighted += $normalizedValue * $grade->evaluation->coefficient->value;
            $sumCoef += $grade->evaluation->coefficient->value;
        }

        return round($sumWeighted / $sumCoef, 2);
    }

    public function calculateClassStatistics(EvaluationId $evaluationId): ClassStatistics
    {
        $grades = $this->gradeRepository->findGradedForEvaluation($evaluationId);
        $values = array_map(fn(Grade $g) => $g->value, $grades);

        sort($values);

        return new ClassStatistics(
            average: count($values) > 0 ? round(array_sum($values) / count($values), 2) : null,
            min: min($values) ?? null,
            max: max($values) ?? null,
            median: $this->calculateMedian($values),
            gradedCount: count($values)
        );
    }
}

Contraintes Techniques

  • Calculs via Domain Events (pas de batch nocturne)
  • Cache Redis pour moyennes fréquemment consultées
  • Précision : 2 décimales, arrondi au plus proche
  • Performance < 10ms (NFR-P9)

FRs/NFRs Couverts

  • FR13 : Calcul automatique des moyennes
  • NFR-P9 : Calculs < 10ms

Structure Fichiers

backend/src/Scolarite/
├── Application/
│   ├── Service/
│   │   └── AverageCalculator.php
│   └── Listener/
│       ├── OnNotePubliee.php
│       └── OnNoteModifiee.php
└── Infrastructure/
    ├── Cache/
    │   └── RedisAverageCache.php
    └── Api/
        └── AverageController.php

Références

  • [Source: _bmad-output/planning-artifacts/epics.md - Epic 6 Story 6.3]

Dev Agent Record

Agent Model Used

Claude Opus 4.6

Debug Log References

Completion Notes List

  • T1 : Service AverageCalculator dans Domain/Service/ — calcul pondéré des moyennes matières (normalisation /20), moyenne générale (arithmétique), statistiques de classe (min/max/moyenne/médiane). Value Object ClassStatistics et DTO GradeEntry. 19 tests unitaires.
  • T2 : Event handlers Symfony Messenger pour NotesPubliees, NoteSaisie|NoteModifiee, EvaluationModifiee, EvaluationSupprimee. Logique commune extraite dans RecalculerMoyennesService. Requêtes batch via findByEvaluations() (N+1 résolu). 8 tests unitaires.
  • T3 : Cache Redis via Symfony Cache pools (TTL 5 min configurable). Decorators CachingStudentAverageRepository et CachingEvaluationStatisticsRepository avec invalidation sur écriture/suppression. 7 tests unitaires.
  • T4 : Migration Doctrine pour 3 tables dénormalisées (student_averages, student_general_averages, evaluation_statistics). Repositories Doctrine avec UPSERT + DELETE. InMemory implémentations.
  • T5 : 3 endpoints API Platform avec contrôle d'accès (ownership/role), validation UUID, tenant isolation. LEFT JOIN pour stats partielles.
  • Backfill : Commande app:recalculer-moyennes pour remplir les projections depuis les données historiques (--tenant optionnel). 4 tests unitaires.
  • Code Review fixes : N+1 queries résolu, NoteSaisie handler ajouté, null averages cleanup, EvaluationModifiee/Supprimee handlers, FQCN corrigé, equals() standardisé, UUID validation 400 vs 500, LEFT JOIN.

File List

  • backend/src/Scolarite/Domain/Service/AverageCalculator.php (new)
  • backend/src/Scolarite/Domain/Service/GradeEntry.php (new)
  • backend/src/Scolarite/Domain/Model/Evaluation/ClassStatistics.php (new)
  • backend/src/Scolarite/Domain/Repository/EvaluationStatisticsRepository.php (new)
  • backend/src/Scolarite/Domain/Repository/StudentAverageRepository.php (new)
  • backend/src/Scolarite/Domain/Repository/EvaluationRepository.php (modified)
  • backend/src/Scolarite/Domain/Repository/GradeRepository.php (modified)
  • backend/src/Scolarite/Application/Port/PeriodFinder.php (new)
  • backend/src/Scolarite/Application/Port/PeriodInfo.php (new)
  • backend/src/Scolarite/Application/Service/RecalculerMoyennesService.php (new)
  • backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineEvaluationRepository.php (modified)
  • backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineGradeRepository.php (modified)
  • backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineEvaluationStatisticsRepository.php (new)
  • backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineStudentAverageRepository.php (new)
  • backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryEvaluationRepository.php (modified)
  • backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryGradeRepository.php (modified)
  • backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryEvaluationStatisticsRepository.php (new)
  • backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryStudentAverageRepository.php (new)
  • backend/src/Scolarite/Infrastructure/Service/DoctrinePeriodFinder.php (new)
  • backend/src/Scolarite/Infrastructure/EventHandler/RecalculerMoyennesOnNotesPublieesHandler.php (new)
  • backend/src/Scolarite/Infrastructure/EventHandler/RecalculerMoyennesOnNoteModifieeHandler.php (new)
  • backend/src/Scolarite/Infrastructure/EventHandler/RecalculerMoyennesOnEvaluationModifieeHandler.php (new)
  • backend/src/Scolarite/Infrastructure/EventHandler/RecalculerMoyennesOnEvaluationSupprimeeHandler.php (new)
  • backend/src/Scolarite/Infrastructure/Cache/CachingStudentAverageRepository.php (new)
  • backend/src/Scolarite/Infrastructure/Cache/CachingEvaluationStatisticsRepository.php (new)
  • backend/src/Scolarite/Infrastructure/Console/RecalculerToutesMoyennesCommand.php (new)
  • backend/src/Scolarite/Infrastructure/Api/Resource/EvaluationStatisticsResource.php (new)
  • backend/src/Scolarite/Infrastructure/Api/Resource/StudentAveragesResource.php (new)
  • backend/src/Scolarite/Infrastructure/Api/Resource/ClassStatisticsResource.php (new)
  • backend/src/Scolarite/Infrastructure/Api/Provider/EvaluationStatisticsProvider.php (new)
  • backend/src/Scolarite/Infrastructure/Api/Provider/StudentAveragesProvider.php (new)
  • backend/src/Scolarite/Infrastructure/Api/Provider/ClassStatisticsProvider.php (new)
  • backend/migrations/Version20260329082334.php (new)
  • backend/config/packages/cache.yaml (modified)
  • backend/config/services.yaml (modified)
  • backend/tests/Unit/Scolarite/Domain/Service/AverageCalculatorTest.php (new)
  • backend/tests/Unit/Scolarite/Infrastructure/EventHandler/RecalculerMoyennesOnNotesPublieesHandlerTest.php (new)
  • backend/tests/Unit/Scolarite/Infrastructure/EventHandler/RecalculerMoyennesOnNoteModifieeHandlerTest.php (new)
  • backend/tests/Unit/Scolarite/Infrastructure/Cache/CachingEvaluationStatisticsRepositoryTest.php (new)
  • backend/tests/Unit/Scolarite/Infrastructure/Cache/CachingStudentAverageRepositoryTest.php (new)
  • backend/tests/Unit/Scolarite/Infrastructure/Console/RecalculerToutesMoyennesCommandTest.php (new)