Files
Classeo/_bmad-output/implementation-artifacts/6-3-calcul-automatique-des-moyennes.md
Mathias STRASSER e745cf326a
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-02 06:45:41 +02:00

278 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Story 6.3 : Calcul Automatique des Moyennes
Status: in-progress
---
## 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)