# Améliorations de performance — à traiter plus tard

*Contrairement à `documentation/backlog-v2.md` (fonctionnalités
volontairement laissées de côté) et `documentation/decisions-a-prendre.md`
(questions métier bloquantes), ce document liste des points
techniques de robustesse/performance identifiés mais non traités —
aucun n'est bloquant aujourd'hui, tous reposent sur l'hypothèse
répétée "volumétrie mono-site modeste", qui n'a **jamais été mesurée
empiriquement**. À réévaluer si le volume réel s'avère plus élevé que
prévu, ou avant un passage multi-site.*

## Constat général

Aucun test de charge n'a été fait sur cette application, à aucun
moment de son développement. `TresorerieService`, `StatistiquesService`
et `ExportReversibiliteService` avaient tous les trois le même profil
(agrégation en PHP après récupération d'une liste complète, sans
pagination) — un choix assumé et documenté comme raisonnable "pour une
volumétrie mono-site modeste", mais jamais vérifié sur un jeu de
données réaliste. Les trois sont corrigés : `ExportReversibiliteService`
le 2026-08-05, `StatistiquesService` et `TresorerieService` le
2026-08-07 (cf. sections dédiées ci-dessous).

## Requêtes sans pagination (`findAll()` / `findParEtablissement()` sans borne)

Toutes chargent la table entière en mémoire à chaque appel. Risque
croissant avec l'ancienneté de la crèche (plusieurs années
d'historique), pas avec le nombre d'enfants actifs (qui reste petit
par nature) :

- `FactureRepository::findParEtablissement()` (`src/Repository/FactureRepository.php:58`)
  — encore utilisé par `ExportComptableService` (désormais en flux,
  cf. section dédiée). **Le plus à risque à l'origine** : une facture
  par mois par enfant, jamais purgée (exclue de la purge §2.4, durée
  légale non chiffrée) — grossit indéfiniment sur la durée de vie de
  la crèche.
- `PaiementRepository::findParEtablissement()` (`src/Repository/PaiementRepository.php:41`)
  — même profil de croissance que les factures ; idem, encore utilisé
  par `ExportComptableService` en flux.
- `MouvementFondateurRepository::findValideesPourEtablissement()`,
  `DepenseRepository::findValideesPourEtablissement()` — n'alimentent
  plus `TresorerieController` (agrégation SQL depuis le 2026-08-07,
  cf. section dédiée) ; encore utilisées ailleurs pour de vraies listes
  bornées (écran de saisie, etc.).
- `EnfantRepository::findParEtablissement()`,
  `PreInscriptionRepository::findToutes()`/`findParEtablissement()`,
  `AnnonceRepository`/`EvenementRepository`/`SondageRepository::findParEtablissement()`,
  `GrilleTarifaireRepository::findParEtablissement()` — volumétrie
  naturellement bornée (effectif, inscriptions/an, annonces/mois) ou
  catalogue de config, risque bien plus faible.

**Piste de correction** : pagination (offset/limite) faite sur les
écrans de liste eux-mêmes le 2026-08-07 (Dépenses, Journal, Avances,
Factures/Paiements, Dossiers enfants — cf. `documentation/backlog-v2.md`),
sur les exports comptables le même jour, et sur
`TresorerieService`/`StatistiquesService` (agrégation SQL plutôt que
liste complète chargée en PHP) le même jour — cf. sections dédiées
ci-dessous.

## Pattern N+1 sur les écrans de statut de paiement

**Partiellement corrigé le 2026-08-07** : `SuiviPaiementsController`
(devenu `SuiviPaiementsController::donnees()`, écran paginé) ne fait
plus ce N+1 — `FactureRepository::findParEtablissementPagine()` calcule
le statut de paiement en SQL (sous-requêtes corrélées sur `Paiement`/
`CorrectionPaiement`, cf. son docblock) et retourne des
`LigneFacturePaiement` déjà prêts, zéro requête `PaiementService` par
facture.

**Corrigé le 2026-08-07** : `StatistiquesService::impayes()`/
`chiffreAffaires()` délèguent maintenant à
`FactureRepository::findImpayees()`/`totalMontantNonAnnule()` — même
principe SQL que `findParEtablissementPagine()` (sous-requêtes
corrélées répliquant `PaiementService::sommePayee()`), plus
`NOT EXISTS (... avoir_facture ...)` répliquant
`AvoirFactureService::estAnnulee()`. `chiffreAffaires()` est descendu à
1 seule requête d'agrégat (`SUM`) ; `impayes()` à une poignée de
requêtes au total (id+montants filtrés en SQL, puis hydratation des
`Facture`/`Enfant` uniquement pour les lignes retenues), au lieu de
~3N requêtes + hydratation ORM complète de tout l'historique. Vérifié
manuellement : résultat comparé à l'ancienne logique recalculée à la
main sur des données réelles incluant une facture annulée (avoir
validé, correctement exclue) et une facture avec avoir refusé
(correctement conservée) — résultats identiques ; tableau de bord et
export CSV re-testés en navigateur.

`exporterImpayes()` (CSV) garde sa construction en mémoire
(`genererCsv()` interne) — pas de risque volumétrique ici, la liste
d'entrée est maintenant filtrée en SQL aux seules factures impayées
(naturellement bornée par l'activité en cours, pas par tout
l'historique) : pas le même profil que les exports comptables
ci-dessus.

## Exports comptables — passés en flux (2026-08-07)

`ExportComptableService`/`ExportComptableController` (§7.2.i : CSV
factures/paiements/dépenses + ZIP annuel) avaient 3 points de
matérialisation complète en mémoire : hydratation ORM de tout
l'historique via `findParEtablissement()` sans borne, construction du
CSV entier comme une seule chaîne PHP, et pour le ZIP,
`file_get_contents()` rechargeant l'archive entière en mémoire pour la
renvoyer (le pire cas : double la RAM utilisée par la taille de
l'archive, potentiellement des dizaines à centaines de Mo avec
beaucoup de pièces jointes photo/PDF sur une année).

**Corrigé** avec le même remède que `ExportReversibiliteService`
ci-dessous : les repositories exposent maintenant
`requeteParEtablissement()` (retourne la `Query` Doctrine, pas son
résultat) ; le service itère via `toIterable()` + `EntityManager::clear()`
périodique (tous les 500 enregistrements) et écrit directement sur le
flux de sortie (`fputcsv` sur `php://output`) plutôt que de construire
une chaîne ; le contrôleur renvoie un `StreamedResponse` (CSV) ou un
`BinaryFileResponse` avec `deleteFileAfterSend` (ZIP, déjà construit
sur disque via `tempnam()`) plutôt qu'un `Response` tamponné. Piège
rencontré : après un `EntityManager::clear()` en cours de boucle,
l'utilisateur qui exporte (`$exportePar`) devient une entité détachée
— `JournalAuditService::enregistrer()` échouerait à la persister ;
corrigé en capturant son id avant la boucle et en récupérant une
référence fraîche (`EntityManager::getReference()`) juste avant de
journaliser. Vérifié manuellement : les 4 exports téléchargés sous une
session Comptabilité réelle, contenu identique à avant, et
`JournalAudit` bien écrit avec le bon `utilisateur_id` malgré le(s)
`clear()`.

Hors scope (comme pour `ExportReversibiliteService`) : un streaming
réseau réellement constant nécessiterait des requêtes MySQL non
bufferisées côté driver — non traité, cf. section suivante.

## Trésorerie — agrégation passée en SQL (2026-08-07)

`TresorerieService::calculerVueDEnsemble()` (§7.2.h, écran recalculé à
chaque affichage, aucun cache) chargeait 4 listes complètes en mémoire
(`Paiement`, `MouvementFondateur` validés, `Depense` validées,
`Remboursement` validés) puis sommait par catégorie en PHP — un
`GROUP BY` fait à la main côté application.

**Corrigé** : chaque repository expose maintenant un agrégat SQL —
`PaiementRepository::sommeParEtablissement()`/
`RemboursementRepository::sommeValideesParEtablissement()` (1 requête
`SUM`, ces deux entités n'ont qu'une seule catégorie),
`DepenseRepository::sommesValideesParCategorie()`/
`MouvementFondateurRepository::sommesValideesParCategorie()` (`GROUP BY`
catégorie, exclusion de `DEPENSE_PERSONNELLE_A_REMBOURSER` faite dans
la clause `WHERE` plutôt qu'en PHP). Le service ne fait plus que 4
requêtes au total, quel que soit le nombre de mouvements — avant,
c'était 4 requêtes **de liste** dont la taille grossissait avec
l'historique. Vérifié : résultat comparé à l'ancienne logique
recalculée à la main sur des données réelles (paiements, dons,
dépenses) — identique ; écran retesté en navigateur.

## Export de réversibilité — tampon réseau du driver DB

`ExportReversibiliteService` (corrigé le 2026-08-05, cf.
`documentation/decisions-a-prendre.md`) écrit maintenant en flux
(curseur Doctrine + écriture ligne par ligne + `EntityManager::clear()`
périodique) — élimine le risque principal (hydratation ORM complète +
`json_encode()` d'un bloc). **Non traité** : les requêtes MySQL/PDO
sont "buffered" par défaut (le driver charge le jeu de résultats
complet côté client avant que Doctrine ne commence à itérer) — un
streaming réseau réellement constant nécessiterait des requêtes non
bufferisées, un changement de configuration de connexion global (donc
risqué : affecterait toute requête concurrente sur la même connexion),
hors de portée d'une correction ciblée sur une seule commande.

→ À ne traiter que si `JournalAudit` (ou une autre table) atteint un
volume qui rend ce tampon lui-même problématique en pratique — pas de
symptôme connu aujourd'hui.
