# 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
- [x] Note publiée → moyenne matière élève recalculée
- [x] Moyenne générale élève recalculée
- [x] Moyenne de classe pour l'évaluation recalculée
- [ ] Calcul < 10ms (NFR-P9)

### AC2 : Calcul moyenne matière
- [x] Formule : Σ(note × coef) / Σ(coef)
- [x] Coefficients de chaque évaluation appliqués
- [x] Notes "absent" et "dispensé" exclues

### AC3 : Calcul moyenne générale
- [x] Coefficients matières appliqués (si configurés)
- [x] Si pas de coef matière : moyenne arithmétique des moyennes matières
- [x] Matières sans note exclues

### AC4 : Statistiques classe
- [x] Moyenne de classe : min / max / moyenne / médiane
- [x] Absents et dispensés exclus

### AC5 : Recalcul sur modification
- [x] Note modifiée → moyennes impactées recalculées immédiatement
- [x] 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)
- [x] T1.1 : Service `AverageCalculator`
- [x] T1.2 : Calcul moyenne matière avec coefficients
- [x] T1.3 : Calcul moyenne générale
- [x] T1.4 : Calcul statistiques classe
- [x] T1.5 : Tests unitaires

### T2 : Domain Events triggers (AC: #5)
- [x] T2.1 : Listener `OnNotePubliee` → recalcul
- [x] T2.2 : Listener `OnNoteModifiee` → recalcul
- [x] T2.3 : Tests intégration

### T3 : Cache Redis (AC: #1)
- [x] T3.1 : Cache moyennes fréquemment consultées
- [x] T3.2 : Invalidation sur modification
- [x] T3.3 : TTL configurable
- [x] T3.4 : Tests

### T4 : Stockage moyennes (AC: #1-4)
- [x] T4.1 : Table `student_averages` dénormalisée
- [x] T4.2 : Table `class_statistics` dénormalisée
- [x] T4.3 : Mise à jour via events
- [x] T4.4 : Tests intégration

### T5 : API Endpoints (AC: #1-4)
- [x] T5.1 : `GET /api/students/{id}/averages` - Moyennes élève
- [x] T5.2 : `GET /api/classes/{id}/statistics` - Stats classe
- [x] 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

```sql
-- 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

```php
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)
