# État du projet

> Dernière mise à jour : 2026-09-29

## Contexte

**Poussé sur `origin/main` le 2026-08-24** — dépôt privé
[laigneletdavid/caisse](https://github.com/laigneletdavid/caisse). `develop` a été fusionnée
dans `main` ; les deux sont identiques. Le travail n'est donc plus seulement sur la machine de
dev.

`develop` reste la branche de travail et **n'a pas de ref distante** : le dépôt n'affiche
qu'une branche. Rien n'est perdu pour autant, tous ses commits sont dans `main` — c'est le ref
qui manque, pas l'historique. `git push -u origin develop` s'en charge si vous voulez la garder
au chaud.

## Mise en ligne — en cours

Hébergement **mutualisé OVH**, en préparation depuis le 2026-08-24.

| | |
|---|---|
| Domaine | `recre.comwebsolutions.fr` |
| Dossier racine | `./recre/public` — le document root pointe bien sur `public/` |
| CDN | activé |
| Base | MySQL, en cours de création |

La marche à suivre est dans [`docs/CHECKLIST_PROD.md`](docs/CHECKLIST_PROD.md).

**L'hébergeur est désormais connu — c'est OVH.** C'est l'un des quatre points qui bloquent la
mise en service réelle : il peut être renseigné dans les mentions légales et les CGU, qui
affichent encore un bandeau « à compléter ».

| | |
|---|---|
| Tests | 403 tests, 1198 assertions, verts |
| Migrations | 20 |
| Entités | 16 |
| Documentation | 13 ADR acceptés, 17 specs toutes terminées |
| `composer audit` | propre |

**Accès** : `http://localhost:8088` depuis le poste, `http://192.168.1.200:8088` depuis un
téléphone du même Wi-Fi (aucune redirection de port à poser — voir `docs/conventions.md`).

**Données** : le bac à sable *Asso de test* a été **purgé le 2026-08-23**, avec son compte de
démonstration — 122 lignes effacées en une transaction. Il ne reste que *ALESAT Adriana*
(7 produits, 2 caisses), dont `laignelet.david@gmail.com` est de nouveau l'administrateur, et le
super-admin `david@comwebsolutions.fr`.

Un compte qui a vendu **ne peut pas être effacé** : `app_sale`, `app_cash_session`,
`app_inventory` et `app_commande` sont en `RESTRICT`. C'est voulu — voir l'ADR-0011 —, et c'est
pourquoi la purge a dû emporter l'association entière plutôt que le seul compte.

**Une session de caisse est ouverte sur Bar Foyer depuis le 2026-06-23**, avec 100 € de fond.
Tant qu'elle l'est, aucune autre ne peut s'ouvrir sur cette caisse.

**Les deux comptes verront l'écran d'acceptation des CGU à leur prochaine connexion.**
La migration ne les a pas pré-remplis : cocher la case à leur place aurait fabriqué une
acceptation que personne n'a donnée.

## Fait

### Socle

- Docker (nginx `:8088`, PHP 8.3, MariaDB 11 `:3308`) + Symfony 7 + Webpack Encore.
- **Heure locale française** ([ADR-0009](docs/decisions/0009-heure-locale-francaise.md)) : PHP
  tournait en UTC, tous les horodatages s'affichaient deux heures en arrière et « aujourd'hui »
  courait de 02:00 à 02:00. `date.timezone = Europe/Paris` dans `docker/php/recre.ini`. Les
  lignes écrites avant restent en UTC — données de dev, jamais poussées.
- Authentification : authenticator custom, login, inscription, redirection post-login selon le
  rôle, hiérarchie de rôles, `access_control`, commande de création du super-admin.
- Correctif `bcmath` : l'extension manquait dans l'image PHP alors que les services de vente et
  de caisse appellent `bcadd`/`bcsub`/`bcmul`. **Toute vente et toute clôture plantaient.**
  Trouvé par les premiers tests.
- Sécurité : `easycorp/easyadmin-bundle` monté en 4.29.16 (avis GHSA-g2fm-8hr4-j82h), contrainte
  resserrée à `^4.29.16`.

### Multi-tenant

- `Association` + filtre Doctrine appliqué aux 7 entités métier, plus `CashCountLine`.
  Migration `Version20260820050035` avec rétro-remplissage.
- **Gardes de rôle sur le back-office** : `AssociationCrudController` et `UserCrudController`
  sont réservés à `ROLE_SUPER_ADMIN`. Sans elles, un admin pouvait atteindre la liste complète
  des associations par l'URL — le filtre ne protège pas `Association`, puisqu'il filtre *par*
  association.
- Écran **« Mon association »** pour les admins : nom et toggles modifiables, identifiant et
  statut en lecture seule. Isolation portée à la main et couverte par 4 tests.
- **Unicité de l'e-mail vérifiée hors du filtre.** `#[UniqueEntity]` interrogeait le dépôt en
  DQL, donc sous le filtre multi-tenant : une adresse détenue par le super-admin ou par une
  autre asso échappait au contrôle, et c'est la base qui refusait — page 500 au lieu d'un
  message sous le champ. Le contrôle passe par une requête native, qui contourne le filtre tout
  en hydratant par l'ORM.

### Vente

- Front de caisse mobile-first : choix de la caisse, ouverture avec fond, grille produits,
  **ajout au panier en tapant sur la carte**, total temps réel, paiement espèces/SumUp.
- [ADR-0007](docs/decisions/0007-vente-jamais-bloquee-par-stock.md) : une vente n'est **jamais**
  refusée pour un motif de stock. Le stock peut passer sous zéro, la carte produit le signale.
- `createSale` tient dans une transaction et verrouille les produits en écriture : deux ventes
  simultanées ne perdent pas un décrément.
- Les actions « Clôturer » et « Retour aux caisses » ont été **retirées de l'écran de vente** :
  trop dangereuses sous le pouce pendant qu'on encaisse. Le chemin passe par le menu.

### Stock

- Tout mouvement passe par `StockService`, seul écrivain de `Product.stock`, qui reste la somme
  exacte du journal. Mouvements non modifiables dans le back-office, historique filtrable,
  ajustement sans motif refusé.
- **Démarque** ajoutée aux motifs, et « Vente » retirée du formulaire : une vente est produite
  par la caisse, pas décidée dans un formulaire. Motif obligatoire pour une démarque comme pour
  un ajustement.
- **Traçabilité** : chaque mouvement retient son auteur, renseigné automatiquement, affiché en
  liste et filtrable. Une vente attribue le mouvement au vendeur qui l'a tapée. Les mouvements
  antérieurs restent sans auteur — on ne réécrit pas un journal.
- **Rayons** : regroupement facultatif des produits ([rayons.md](docs/specs/rayons.md)). Un
  produit sans rayon se vend et se compte comme les autres, deux tests le verrouillent.

### Clôture

- [ADR-0008](docs/decisions/0008-comptage-a-l-aveugle.md) : **comptage à l'aveugle par coupure**.
  Fiche des 14 coupures de la série euro (1 c à 200 €, le 500 € écarté), sous-totaux et total
  calculés, détail conservé en base. Champ de total global en repli.
- **Aucun montant attendu n'apparaît avant validation** — un test le vérifie littéralement.
- Rapprochement du total SumUp, avec un **écart carte tenu séparément** de l'écart espèces.
- Écran de bilan après validation : attendu, compté, écart, pour les deux moyens.
- Récapitulatif de **vérification** avant la clôture, qui est irréversible : « VÉRIFIEZ VOTRE
  COMPTAGE » en rouge, le **détail coupure par coupure** avec les quantités, et un retour à la
  grille. Une quantité aberrante ne se voit pas dans un total, elle saute aux yeux sur sa ligne.
  Il ne montre que ce qui a été saisi, jamais l'attendu. C'est ce qui remplace la « correction
  après révélation », qui rouvrait le biais de l'ADR-0008.
- **Fond de caisse standard par caisse**, réglé une fois dans le back-office. Pré-rempli à
  l'ouverture (proposé, jamais imposé), et déduit du comptage à la clôture pour donner la
  **recette à remettre** au trésorier. Un tiroir qui ne couvre pas le fond standard ne produit
  pas de recette négative : tout y reste.
- Une seule session `OPEN` par caisse, garantie en base par une colonne sentinelle et un index
  unique.

### Inventaire

- [inventaire.md](docs/specs/inventaire.md) : le **vendeur compte** sur le front, devant le
  carton ; l'**admin valide** depuis le back-office. Celui qui compte n'est pas celui qui décide.
- Comptage à l'aveugle : aucun stock enregistré à l'écran de saisie, et **aucun écart montré au
  vendeur** après l'envoi — l'écart appartient à celui qui valide.
- Un champ vide vaut « pas compté », un zéro vaut « il n'y en a plus ». Ce n'est pas la même
  chose, et l'écran le dit.
- **Un seul tableau à la validation.** Les produits qui tombaient juste étaient repliés dans un
  bloc à part : il fallait lire deux endroits pour savoir ce qui avait été compté. Ils sont
  maintenant dans le tableau, à leur place, les plus gros écarts en tête.
- **Le vert est réservé au comptage qui tombe juste.** Un excédent était affiché en vert, comme
  une bonne nouvelle : c'en est une mauvaise — il dit que des entrées ont été oubliées, ou que le
  comptage est faux. Manque et excédent prennent **tous deux le rouge** : distinguer par une seconde couleur ne
  faisait qu'hésiter sur la gravité. Le signe porte le sens, la couleur dit « celui-là,
  regarde-le ». Ces deux nuances sont **propres à l'inventaire** : ailleurs — écart
  de caisse, évolution du chiffre — un zéro veut dire « stable », pas « conforme ».
- Corrigé au passage : sur un inventaire **clos**, le nombre d'écarts affiché était recalculé
  contre le stock d'aujourd'hui — donc zéro par construction, puisque c'est précisément dessus
  qu'on venait de le recaler. Il lit maintenant l'écart figé à la validation.
- L'écart se calcule **à la validation**, contre le stock du moment où l'on tranche. Le calculer
  à la saisie ferait qu'un inventaire validé le lendemain réintroduirait l'écart qu'il corrige.
- La validation passe un `ADJUSTMENT` par produit divergent, via `StockService`, en transaction.
  Un refus ne touche à rien et garde le comptage dans l'historique.

### Corriger une vente

- [ADR-0012](docs/decisions/0012-annulation-de-vente.md) et
  [corrections.md](docs/specs/corrections.md) : **on contre-passe, on n'efface pas.** Une vente
  fausse reste dans le journal, marquée, avec qui, quand et pourquoi. Motif obligatoire.
- **Pas de correction partielle** — on annule et on resaisit. Modifier obligerait à répondre à
  « quelle version fait foi ? » ; annuler ne pose jamais la question. Et **une annulation ne
  s'annule pas**, sinon on empile des états sur une même ligne.
- **La clôture d'une session close ne bouge jamais** : ni son attendu, ni son écart, ni son
  motif. Le stock revient, daté du jour. Recalculer un comptage constaté viderait l'ADR-0008 de
  son sens — c'est la décision qui porte tout le reste, et un test la verrouille.
- Le **vendeur** annule sa propre vente dans sa session ouverte, l'**admin** n'importe laquelle
  de son association. Un seul mécanisme, deux portées.
- La vente annulée **et** son annulation sortent des statistiques **dans les deux sens** :
  retirer la vente puis compter l'annulation en négatif soustrairait deux fois. L'exclusion est
  posée dans les deux dépôts qui lisent les ventes — le seul endroit où l'oublier serait
  possible.
- Un mouvement de stock **`ANNULATION`**, pas un `ENTRY` : dans le journal comme dans le
  cadencier, une marchandise qui arrive et une vente qui n'a pas eu lieu ne sont pas le même
  fait. Son motif porte le numéro de la vente.
- **L'écran des corrections est ouvert à tous les membres**, vendeurs compris. Si seul celui qui
  corrige voit les corrections, ce n'est pas de la transparence.
- L'écran de confirmation annonce **ce qui va se passer**, et pas la même chose selon que la
  caisse est ouverte ou close. Même dispositif qu'au récapitulatif de clôture : on montre avant.
- Trois colonnes sur `Sale`, pas d'entité : une annulation ne survit pas à sa vente. Le montant,
  les lignes, le vendeur et la date restent intouchés — on annote, on ne réécrit pas.
- L'écran de vente **reste nu** : un lien discret mène aux ventes de la caisse, pas un panneau.

### Alertes et tableau de bord

- Seuils par association : stock bas (5 par défaut) et écart de caisse (5,00 € par défaut),
  réglables depuis « Mon association ».
- `AlerteService` calcule tout **à la lecture** : aucune table d'alertes, aucune tâche planifiée,
  rien qui puisse devenir faux pendant qu'on regarde ailleurs.
- Trois niveaux de stock — à recommander, en rupture, **suivi décroché**. Le troisième est le plus
  important : il ne dit pas qu'il manque de la marchandise, il dit que le chiffre ment.
- `/admin` ouvre désormais sur un vrai tableau de bord : CA de la semaine, produits à regarder,
  sessions dont l'écart sort de l'ordinaire, meilleures ventes. Il remplace la redirection vers
  la liste des produits.
- Pastilles de comptage dans le menu, lignes marquées dans la liste des produits et dans celle
  des sessions. Le calcul est partagé par une extension Twig : impossible qu'un écran signale une
  ligne que l'autre ignore.
- **Motif obligatoire au-delà du seuil** à la clôture. L'écart est révélé sur un écran dédié, un
  motif est demandé, et la grille de comptage n'est pas rouverte — la rouvrir après la révélation
  rendrait la correction dépendante de l'écart. La clôture n'est jamais refusée pour autant.

### Statistiques

- Résumé des ventes, par vendeur, par produit, écarts de caisse. Périodes calendaires ou plage
  personnalisée, filtre par caisse sur les écarts, colonne marge si le toggle est actif.
- **Correctif du filtre de période.** Le menu mène aux stats par `/admin?routeName=admin_stats` ;
  le formulaire n'avait pas d'`action`, se soumettait sur cette URL en écrasant la query string,
  perdait `routeName`, et `/admin` renvoyait sur la liste des produits. Action explicite, plus un
  test qui verrouille la destination depuis les deux chemins d'accès.
- **Écarts SumUp** dans le tableau des écarts : deux groupes de colonnes — espèces et carte, avec
  attendu, compté et écart — et **deux totaux séparés**. Les additionner effacerait ce qui
  distingue une erreur de comptage d'un moyen de paiement mal saisi.
- Modifier une date bascule la période sur « Plage personnalisée » : le select et les champs de
  date ne se contredisent plus en silence.
- **Filtres sur les ventes** : par jour et par caisse. Le filtre caisse est fait maison, une
  vente portant une session et non une caisse.

### Statistiques : la profondeur

- **Ruptures reconstruites** depuis le journal des mouvements : `StockHistoryService` remonte le
  temps à partir du stock actuel. Jours à zéro et jours en négatif comptés séparément.
- Trois garde-fous, chacun sous test : la série ne remonte pas avant la **première écriture** du
  produit, ne va pas **au-delà d'aujourd'hui**, et les deux moitiés de l'estimation portent sur
  la **même fenêtre**.
- **Ventes perdues estimées** — jours sans stock × ventes d'un jour ordinaire. Signalée comme
  estimation partout où elle apparaît : on ne saura jamais combien de clients sont repartis les
  mains vides.
- **20/80** avec part cumulée et seuil, **évolution** contre la période comparable précédente
  (aucun pourcentage depuis zéro : un démarrage n'est pas une hausse de 100 %), groupement par
  rayon, meilleures ventes en quantité et en CA séparément.
- **Fiche produit consultable** — elle n'existait pas. Stock, CA, évolution sur deux semaines,
  jours de rupture, historique du stock jour par jour, journal des mouvements avec leur auteur.
- **Exports PDF** des statistiques générales et d'une fiche produit, reprenant les filtres en
  cours. Dompdf, sans aucune ressource distante. Les graphiques sont en CSS pur : un canvas
  JavaScript ne se rendrait pas dans un PDF.
- **Aucun des trois n'était atteignable depuis son écran.** Deux causes, même famille :
  la fiche produit passait par `AdminUrlGenerator`, qui rendait `/admin?crudAction=export_pdf&…`
  et retombait sur le tableau de bord ; les statistiques et le cadencier recopiaient la query
  string en cours, donc `routeName` quand on arrivait **par le menu** — EasyAdmin détournait
  alors la requête et renvoyait 90 ko de HTML au lieu du PDF.
- Les liens portent désormais des **filtres nommés un par un** (période, dates, caisse, rayon…)
  et jamais la plomberie d'EasyAdmin. La fiche produit passe par sa route nommée.
- **En-tête et pied sur chaque page** : la marque et l'association en haut, la mention de droits
  « © 2026 Récré — créé et développé par David Laignelet », la date d'édition et « Page 2 / 5 »
  en bas. Ils sont en `position: fixed`, donc repeints à chaque page : un export de statistiques
  fait volontiers cinq pages, et une page détachée du reste doit encore dire d'où elle vient.
- Le **total de pages** ne peut pas venir du CSS : `counter(pages)` rend zéro dans un élément
  fixe, parce que Dompdf le peint avant de savoir combien de pages il aura. Seul ce total passe
  par `page_text`, après le rendu ; le reste du pied vit dans le gabarit.
- La palette du PDF est passée à Organic : elle était restée au vert acide du « Cahier ».
- **Les tests ne voyaient rien** parce qu'ils attaquaient les URL nues, où tout marchait. Ils
  partent maintenant du menu et suivent le lien tel que l'écran le rend. C'est la quatrième fois
  que ce détour par la query string casse quelque chose, après le filtre des statistiques et les
  actions de commande.


### Cadencier

- [cadencier.md](docs/specs/cadencier.md) : ventes par produit × période (jour ou semaine),
  profondeur réglable et bornée à 26. Le tableau qu'on ouvre avant de commander.
- La **couverture** porte la décision : combien de périodes le stock tient encore, au rythme où
  le produit part. En dessous de 1, il manquera avant la fin de la période suivante. C'est la
  forme actionnable de la rotation — un taux dit la même chose à l'envers et ne se traduit pas
  en « il faut commander jeudi ».
- Aucune couverture pour un produit qui ne se vend pas ; zéro, pas un négatif, pour un stock
  négatif.
- Un produit sans vente et bien approvisionné est écarté ; un produit sans vente **mais en
  rupture** reste — c'est peut-être parce qu'il manquait qu'il ne s'est rien vendu.
- La période en cours est signalée comme incomplète : sans ça, un mardi matin ressemble à un
  effondrement des ventes.
- Filtres par rayon et par produit, export PDF avec les filtres en cours.


### Audit des mécanismes de stock — 2026-08-22

Un doute sur la cohérence des stocks après un inventaire et une commande. Vérification faite sur
les données réelles **et** sur le code : `Product.stock` est bien égal à la somme des mouvements
pour tous les produits de l'Asso de test, et les deux mécanismes font exactement ce qu'ils
annoncent. Ce qui manquait, c'était de quoi le **lire**.

- **Le journal affichait un écart sans son contexte.** « Sucette · Ajustement · +8 » sur un
  produit qui reste à zéro est juste — le stock valait −8, le comptage disait 0 — mais illisible :
  le stock d'alors n'était écrit nulle part. Le motif porte désormais le comptage et le stock
  (« Inventaire du 21/08/2026 — compté 0, stock -8 »), et la quantité commandée pour une entrée
  (« Commande n° 1 — 10 reçus sur 10 commandés »). Les lignes antérieures ne sont pas réécrites.
- **Le récapitulatif de réception masquait les lignes à zéro.** On validait « Chips × 10, Barre
  chocolatée × 1 » sans jamais voir que la troisième ligne ne recevait rien, et la commande se
  fermait dessus — c'est exactement ce qui est arrivé aux sucettes. Toutes les lignes s'affichent
  maintenant, celles à zéro en rouge avec la quantité demandée. Même règle qu'à la clôture de
  caisse : le récapitulatif montre ce qui a été **saisi**, et un zéro saisi est une information.
- Ce qui n'était pas un bug : une sucette jamais entrée en stock, vendue huit fois, tombée à −8
  (l'ADR-0007 ne bloque pas une vente), remise à 0 par un inventaire qui comptait 0.
- **Les deux entrées d'ALESAT sont réconciliées.** Chips et Boissons, +10 chacune le 23 juin,
  écrites avant que `StockService` n'existe et jamais répercutées sur `Product.stock`. Le journal
  fait foi — un mouvement est un fait daté, le stock n'en est que la somme —, donc le stock a été
  recalé sur lui, à 10. **Aucun mouvement compensatoire n'a été créé** : inventer une écriture
  inscrirait dans l'historique un événement qui n'a jamais eu lieu.
- `app:reconcilier-stock` vérifie l'invariant sur toute la base et le répare, **à blanc par
  défaut**. Il n'existait aucun moyen de le contrôler : c'est ainsi que ces deux entrées sont
  restées sans effet deux mois durant sans que personne ne s'en aperçoive. Les douze produits
  sont désormais conformes.

### Commandes et réception

- [commandes.md](docs/specs/commandes.md) et [ADR-0010](docs/decisions/0010-commande-sans-fournisseur.md) :
  une commande n'exprime qu'un **besoin de marchandise**. Aucun fournisseur, aucun envoi, aucune
  facture — elle ne quitte jamais l'application.
- **Le vendeur prépare** depuis l'écran de commande, avec une quantité proposée pour revenir à la
  couverture cible de l'association (3 semaines par défaut, réglable). Proposition, jamais
  imposition. **L'admin valide** — ça engage de l'argent.
- **La réception ne repasse pas par l'admin** : une entrée de marchandise est un fait, les cartons
  sont là ou ils n'y sont pas. Un ajustement d'inventaire, lui, est une correction, et ça se
  discute.
- La quantité reçue **n'est pas pré-remplie** avec la quantité commandée : la recopier sans
  regarder est exactement l'erreur qu'on veut éviter.
- Réception partielle admise, réception supérieure au commandé admise. La commande se ferme quand
  même : il n'y a pas de fournisseur qui doive un reliquat.
- La réception passe un `ENTRY` par ligne reçue, via `StockService`, en transaction. **La boucle
  du stock est fermée** : les entrées ont enfin une raison d'être.


### Les chiffres, côté élèves

- Écran `/vente/chiffres` : le chiffre de la période, l'évolution, un histogramme jour par jour,
  le 20/80, les ruptures, et un zoom sur un produit.
- **Tout le monde voit le chiffre de l'association.** C'est une association, pas une entreprise :
  l'intérêt pédagogique est de discuter les chiffres ensemble. La contribution personnelle
  (« dont vous ») sert à se situer, mais **il n'y a pas de classement des vendeurs** — un palmarès
  entre camarades n'apprend rien. La répartition par vendeur reste côté admin, où elle a un sens
  de suivi.
- **Écran séparé de la caisse.** `alertes.md` pose que le front de vente n'affiche aucune donnée
  de gestion : pendant une récré de quinze minutes, on encaisse, on n'analyse pas. Un test
  vérifie que l'écran de vente est resté nu.
- Histogramme en CSS pur, comme partout ailleurs : ce serait 80 ko de bibliothèque pour six barres.


### L'accueil public

- **La création de compte n'est plus en libre-service.** `/inscription` créait une association
  *et* son compte administrateur pour qui trouvait l'URL — en contradiction avec
  [multi-asso.md](docs/specs/multi-asso.md), qui dit depuis le début que le super-admin ouvre les
  associations. La route redirige désormais vers une **demande d'accès**.
- `DemandeAcces` est la **seule entité hors association** : elle existe avant qu'il y en ait une.
  Le filtre multi-tenant ne s'y applique donc pas, et son isolation est portée par une garde
  `ROLE_SUPER_ADMIN`, comme pour `AssociationCrudController`.
- **La demande est enregistrée en base, et c'est ça, la trace** — lue dans le back-office, avec
  une pastille de comptage. Depuis le 2026-08-25, deux messages partent en plus : la notification
  au responsable de l'instance et l'accusé de réception au demandeur. Ils partent **après**
  l'enregistrement et ne peuvent pas le faire échouer
  ([ADR-0013](docs/decisions/0013-mail-ne-bloque-jamais.md)).
- Pied de page avec les liens et la signature, et une page de **mentions légales volontairement
  incomplète, qui le dit** : éditeur, hébergeur et traitement des données restent à renseigner.
  Des mentions inventées donneraient l'illusion que la question est réglée.


### L'aide contextuelle

- **Plus aucun panneau sur le front, depuis le 2026-08-22.** Il y en avait un, replié, en tête de
  chaque écran qui avait quelque chose à expliquer. Sur un téléphone, en pleine récré, il prenait
  la place de ce qu'on venait faire, et personne ne le dépliait. Retiré du gabarit de base, avec
  ses règles CSS devenues mortes ; un test vérifie qu'il ne revient pas.
- **Le back-office garde les siens** — on y est assis, et on y cherche vraiment à comprendre :
  tableau de bord, statistiques, cadencier.
- Le guide complet à `/aide` reste, et porte désormais seul l'aide du front. C'est le document
  qu'on met entre les mains d'un élève qui prend la caisse pour la première fois : il se lit
  d'une traite et s'imprime.
- `<details>` natif : **aucun JavaScript**. Ça marche sur le téléphone d'un élève avec une
  connexion capricieuse, et ça s'imprime ouvert.
- Elle ne dit **pas quoi faire** — les écrans s'en chargent, et un texte qui répète « saisissez
  la quantité dans le champ » n'est lu par personne. Elle dit **pourquoi**, et seulement là où le
  comportement est volontairement contre-intuitif : la vente qui ne se bloque jamais, le comptage
  à l'aveugle, « vide n'est pas zéro », la quantité de réception non pré-remplie, l'estimation
  des ventes perdues.
- Registre unique dans `AideService`, indexé par **nom de route** : pas de clé à poser à la main
  dans les gabarits, donc pas de clé qu'on oublie. Un test échoue si une entrée pointe vers un
  écran disparu — sans lui, l'aide se périmerait en silence.
- Un vendeur ne voit que l'aide des écrans qu'il utilise. Celle du back-office ne ferait que
  noyer la sienne.


### L'import des membres

- [import-membres.md](docs/specs/import-membres.md) : CSV ou XLSX, quatre colonnes —
  **prénom, nom, e-mail, rôle** — jusqu'à 500 lignes. Le rôle vide vaut vendeur, parce que c'est
  de très loin le cas le plus fréquent et qu'on ne fait pas saisir trente fois la même valeur.
- **Analyser d'abord, écrire ensuite.** L'aperçu montre les lignes retenues, les lignes refusées
  et **pourquoi** chacune l'est, sans rien créer. Un import de masse qui s'exécute directement est
  un import qu'on n'ose plus relancer : au moindre doute, on refait tout à la main.
- Les lignes refusées **ne bloquent pas le reste** : une adresse mal tapée sur trente ne condamne
  pas les vingt-neuf autres. La création, elle, est transactionnelle.
- L'unicité de l'e-mail est vérifiée **hors du filtre multi-tenant**, en s'appuyant sur la requête
  native posée le 2026-08-20. Sans ça, l'import casserait au milieu sur une contrainte
  d'intégrité, pour une adresse que le filtre rend invisible.
- Les mots de passe sont **générés et affichés une seule fois**, sur une page faite pour être
  imprimée puis découpée ; en base, seule l'empreinte. **Aucun message ne part, et ça ne changera
  pas** : depuis que Brevo est branché, l'application sait écrire, mais un mot de passe envoyé par
  mail resterait des années dans une boîte. Qui perd le sien passe par « mot de passe oublié ».
  L'alphabet exclut `O`, `0`, `l`, `1` : ces comptes se recopient dans un couloir, sur un
  téléphone.
- Un fichier qui n'est ni CSV ni XLSX est refusé d'emblée : PhpSpreadsheet lit n'importe quel
  fichier texte comme un CSV, et un PDF déposé par erreur rendrait un aperçu de charabia.

### Le mail — 2026-08-25

Spec : [mail.md](docs/specs/mail.md). Décision :
[ADR-0013](docs/decisions/0013-mail-ne-bloque-jamais.md).

- **Brevo est branché** (`symfony/brevo-mailer`), et l'application n'envoie que **trois messages** :
  la notification d'une demande d'accès, son accusé de réception, et le lien de réinitialisation
  d'un mot de passe. Aucune alerte, aucune relance.
- **Un envoi ne fait jamais échouer ce qui l'a déclenché.** C'est la première dépendance du
  projet à un service en ligne ; une panne doit dégrader l'application, pas la casser. L'échec
  est journalisé, pas propagé. La contrepartie : personne n'est prévenu qu'un message n'est pas
  parti, sauf à lire `var/log`.
- **Les mails ne passent pas par la file Messenger.** La recette de Symfony y routait
  `SendEmailMessage` ; sans worker pour consommer, rien ne partait jamais — la fonctionnalité
  était muette et personne ne l'aurait vu, la file se remplissant en silence. Envoi direct
  pendant la requête, borné par `max_duration` (12 s) sur le client HTTP, avec un réessai — sans
  file, une tentative unique perd le message au premier hoquet réseau. Le jour où un envoi
  deviendrait volumineux, il faudra remettre la file **et** le worker : les deux, jamais l'un
  sans l'autre.
- **Mot de passe oublié** : `symfonycasts/reset-password-bundle`, lien à usage unique valable une
  heure. Le jeton n'est pas stocké — la base n'en garde que l'empreinte — et il transite par la
  session plutôt que de rester dans l'URL, donc hors de l'historique du navigateur.
- **L'écran de confirmation est le même pour une adresse connue et pour une inconnue**, et un
  test compare les deux caractère par caractère. Sinon le formulaire devient l'annuaire des
  comptes de l'instance — sur un outil qui gère des comptes d'élèves, ça se paie.
- **Changer son mot de passe** se fait depuis le menu, avec l'ancien exigé : il ne protège pas
  contre celui qui le connaît, mais contre celui qui trouve un écran resté ouvert. Le changement
  met fin à la session, et l'écran le dit **avant**.
- **Un mot de passe n'est jamais envoyé par mail.** Ni à la création d'un compte, ni à l'import.
- Les **CGU ont été corrigées** : elles affirmaient « l'application n'envoie aucun message » et
  « les données ne sont transmises à aucun tiers ». Les deux devenaient faux le jour du
  branchement. Le texte nomme maintenant Brevo comme sous-traitant qui voit l'adresse du
  destinataire et le contenu du message.

**Déploiement du 2026-09-02, et ce qu'il a appris.** Le `vendor/` du serveur datait
d'avant le lot : `config/bundles.php` référençait un bundle absent, et le noyau ne
démarrait plus. Un `composer install` l'a réglé. Deux constats à garder :

- **Le shell SSH du mutualisé n'a pas la sortie réseau du web.** `mailer:test` y
  reçoit un `Connection refused` immédiat sur `api.brevo.com:443` — et une sonde
  TCP y trouve injoignable jusqu'au relais d'OVH. Ça ne dit rien de ce que peut
  faire le site : **EvoHub, sur le même cluster mutualisé, envoie avec la même
  API Brevo et moins de configuration**. Toute vérification d'envoi se fait donc
  depuis le domaine, pas depuis SSH.
- **La production n'avait aucun journal lisible.** `php://stderr` convient à
  Docker, pas à un mutualisé. Un handler fichier écrit désormais `var/log/app.log`
  à chaque envoi, réussi ou raté — sans lui, l'ADR-0013 rend une panne d'envoi
  invisible pour tout le monde.

**Les clés Brevo sont posées** — sur le poste de développement et sur le serveur, dans
`.env.local` des deux côtés ; `.env`, lui, garde `null://null`. Le parcours complet a été vérifié
en local le 2026-09-02 : demande, mail reçu, lien suivi, mot de passe changé. **Côté production,
l'envoi n'est pas encore prouvé** — voir ci-dessous. Les trois lignes à poser sont dans
[`docs/CHECKLIST_PROD.md`](docs/CHECKLIST_PROD.md).

### Coup unique et cadencier côté élèves — 2026-09-10

**Un produit peut sortir du suivi de stock.** Une case sur la fiche produit, décochée pour un sweat
du lycée ou un lot de kermesse. Elle coupe les **interprétations** — ruptures, ventes perdues,
couverture, alertes, proposition de commande — et laisse la **trace** intacte : le stock est
toujours décrémenté, le journal toujours tenu. Un article qu'on ne réapprovisionne pas n'est pas
« en rupture », il est épuisé, ce qui était le plan.

Le défaut est « suivi », posé par la migration sur tous les produits existants : c'est l'exception
qui se déclare.

**Le cadencier est ouvert aux vendeurs**, en lecture seule, depuis « Nos chiffres » et depuis
l'écran des commandes. Ce sont les élèves qui préparent les commandes depuis le lot 6 ; leur cacher
le tableau qu'on ouvre avant de commander revenait à leur demander de décider à l'aveugle. Pas
d'export, pas de profondeur à régler, et l'**écran de vente reste nu** — un test le verrouille,
comme l'exige [alertes.md](docs/specs/alertes.md).

### Le reCAPTCHA du formulaire public — 2026-09-29

Le formulaire de **demande d'accès** est le seul point de l'application ouvert à tout internet :
c'est le seul à protéger, tout le reste est derrière une authentification. reCAPTCHA v2, la case
à cocher.

**Sans clés, il n'existe pas.** Pas de case, pas de vérification, pas de script chargé — le
formulaire se comporte exactement comme avant, en développement comme dans les tests. Même règle
que `MAILER_DSN` à ses débuts. Les clés vont dans `.env.local`, jamais dans `.env`.

**Quand Google ne répond pas, la demande passe quand même.** C'est une décision : ce formulaire
est la seule porte d'entrée d'une association qui veut s'inscrire, et la fermer parce qu'un
service tiers est en panne coûterait plus cher que le spam laissé passer ce jour-là. L'échec est
journalisé, ce qui permet de s'en apercevoir si ça dure.

**Ce que ça change aux CGU.** Elles affirmaient qu'**une seule** donnée sortait du serveur, celle
qui part par e-mail. C'était redevenu faux : Google reçoit l'adresse IP du visiteur et dépose un
cookie. Les CGU et les mentions légales nomment maintenant les deux destinataires, et précisent
que le reCAPTCHA ne concerne que cette page — jamais un membre connecté. C'est le deuxième
sous-traitant à reporter dans le registre des traitements.

### Les lectures annuelles — 2026-09-29

Spec : [stats.md](docs/specs/stats.md). Maquettes validées avant d'écrire une ligne de code.

Une période **année scolaire** (septembre → août) rejoint jour, semaine et mois — une année
civile couperait la seule année qui existe pour une boutique de lycée. Elle porte quatre blocs :

- **CA par mois** et **CA par jour d'ouverture**, en bâtons CSS ;
- **camembert du 20/80**, qui encode un **rang** et non des identités ;
- **saisonnalité en petits multiples**, une courbe par produit à la même échelle.

**Pourquoi pas un camembert multicolore.** La palette Organic est chaude et peu saturée par
construction : passée au validateur, aucune combinaison de sept teintes n'y franchit le seuil de
saturation, la séparation daltonienne ni même la distinction en vision normale. La sauge lit
gris, les neutres échouent tout. Le camembert utilise donc une seule teinte du foncé au clair,
et les courbes sont en petits multiples plutôt que superposées. Ce n'est pas un repli : le 20/80
demande « lesquels font le chiffre », pas « qui est qui ».

Les trois graphiques sont dans `templates/_graphiques.html.twig`, partagé back et front. Le PDF
reprend les bâtons en **barres horizontales** — Dompdf ne fait pas de flexbox.

Côté vendeur, deux blocs seulement : le camembert de l'année et les jours d'ouverture. Sur la
fiche produit : son année, ses jours, et sa part du chiffre de l'association.

### Les jours d'ouverture — 2026-09-10

**Le dénominateur qui manquait.** La correction ci-dessous réparait le numérateur du cadencier ;
il restait à dire quand la boutique ouvre. Un stand qui vend deux récrés par semaine n'a pas cinq
jours creux, il a deux jours de vente — et l'application supposait implicitement sept jours sur
sept, ce qui n'arrive jamais dans un lycée.

L'association déclare ses jours d'ouverture dans « Mon association », en cases à cocher. Le
cadencier mesure alors le rythme **par jour d'ouverture terminé**, et la vue semaine le convertit
en multipliant par le nombre de jours de vente hebdomadaires. Les deux vues cessent de se
contredire :

| | Avant | Après |
|---|---|---|
| Rythme boissons | 32,5/jour calendaire | 32,5 **par jour d'ouverture** |
| Couverture, vue Jour | 2,6 jours | 2,6 **jours d'ouverture** |
| Couverture, vue Semaine | — (rien à mesurer) | **1,3 semaine** (2 récrés/semaine) |

**Vide veut dire tous les jours**, comme les rayons et les moyens de paiement : une association
qui ne déclare rien garde exactement le comportement d'avant, et l'écran l'avertit que le calcul
suppose sept jours de vente par semaine.

**Les ruptures et les ventes perdues suivent aussi, depuis le 2026-09-11.** Elles se comptaient
en jours calendaires : un produit en rupture le dimanche comptait une journée de rupture et une
journée de ventes perdues, boutique fermée. Le rythme qui sert à estimer ces pertes a changé de
dénominateur du même coup — il vaut maintenant les jours où le produit **pouvait** se vendre
(ouvert, en stock, après sa première vente) au lieu des seuls jours où il s'est vendu. Voir
[stats.md](docs/specs/stats.md).

Une limite assumée, écrite dans [cadencier.md](docs/specs/cadencier.md) : les **vacances
scolaires** comptent toujours comme des jours d'ouverture sans vente.

### La couverture du cadencier était fausse — corrigé le 2026-09-10

**Trouvé sur les vraies données de l'ALESAT, le premier jour de vente.** 65 boissons vendues dans
la semaine, 86 en stock, et le tableau annonçait **10,6 semaines de couverture**. La bonne réponse
était 1,3 : il fallait commander tout de suite.

La moyenne divisait le total par la **profondeur demandée** — huit semaines — dont sept où la
boutique n'existait pas. Le rythme était divisé par huit, la couverture multipliée par autant. La
formule tombait juste à la décimale près sur les sept lignes de l'écran, ce qui a permis de
l'identifier sans hésitation.

Deux règles, valables en jour comme en semaine, l'unité suivant le sélecteur :

- **on ne remonte pas avant la première vente du produit** — c'était déjà la règle des ruptures du
  lot 4, que le cadencier contredisait ;
- **la période en cours ne compte jamais dans le rythme**, parce qu'elle est incomplète par nature
  et qu'elle tire la couverture vers le haut — le mauvais côté pour décider d'une commande.

Sans période terminée à mesurer, la moyenne et la couverture valent `null` et l'écran affiche un
tiret : *on ne sait pas encore*. Le sélecteur « Jour » rend le tableau utilisable dès le
lendemain d'une ouverture. Le **total**, lui, continue de tout compter — c'est un fait.

La **proposition de quantité** des commandes s'appuyait sur cette même moyenne : elle proposait
huit fois trop peu. Elle est corrigée par la même modification, et ne propose plus rien tant
qu'aucune période terminée ne mesure le rythme.

### La caisse spécialisée — 2026-09-09

Spec : [caisse-specialisee.md](docs/specs/caisse-specialisee.md). Relevé après la **première
vraie récré avec les élèves** — qui s'est bien passée, et qui a montré ce qui manquait.

- **Une caisse ne vend que ses rayons.** Plusieurs rayons par caisse, et **vide veut dire tous** :
  aucune caisse existante ne change de comportement. Le back-office annonce combien de produits
  n'ont aucun rayon, parce qu'ils disparaissent d'une caisse restreinte — et disparaître en
  silence est le seul comportement qu'on ne pouvait pas accepter.
- **Les moyens de paiement se déclarent par caisse**, même règle du vide. L'écran de vente
  n'offre que ce qui est coché.
- **Le chèque existe**, et il traverse toute la chaîne : attendu, compté, écart, colonne dans les
  statistiques, dans le tableau de bord et dans le PDF. Les trois écarts restent **séparés**.
  Pas de grille de comptage — un chèque n'a pas de dénomination —, mais l'attendu reste caché
  pendant la saisie : c'est ça, l'ADR-0008.
- Le champ chèque apparaît aussi quand la caisse **n'accepte plus** les chèques mais qu'il s'en
  est encaissé : sinon la clôture ne demanderait rien et l'écart tomberait sans recours.
- **Le filtre par caisse des statistiques porte sur toute la page**, plus seulement sur les
  écarts. Les ruptures restent globales, et l'écran le dit : un stock appartient au produit.

**Deux choses trouvées en chemin.** Le filtre des statistiques n'était pas cassé — deux tests le
prennent par l'URL et par le formulaire — mais il ne filtrait que les écarts, ce qui revenait au
même pour qui cherchait le chiffre d'une caisse. Et l'**export PDF transmettait déjà le paramètre
`caisse` en l'ignorant** : on regardait une caisse à l'écran et on téléchargeait le total de
toutes, sans que rien ne le dise.

### La conservation des données

- [ADR-0011](docs/decisions/0011-conservation-trois-ans.md) : **un compte est supprimé trois ans
  après sa dernière utilisation** — dernière connexion, dernière vente, dernier mouvement de
  stock, dernière session, dernier inventaire, dernière commande, ou à défaut sa date de
  création. Une conservation sans durée n'était pas un oubli de rédaction : c'était une décision
  par défaut, prise par personne.
- **Supprimer ne veut pas dire effacer la ligne.** Dix clés étrangères pointent vers `app_user` :
  supprimer en cascade emporterait les ventes, les clôtures et le journal du stock ; détacher les
  références rendrait ce journal inutile — il existe pour dire **qui** a fait quoi. Le compte est
  donc **anonymisé** : nom et adresse écrasés, mot de passe rendu inutilisable, rôles vidés,
  `anonymiseLe` horodaté. La personne disparaît, l'activité reste, sous « Membre #id ».
- Trois colonnes portent le calcul : `createdAt`, `lastLoginAt`, `anonymiseLe`. La dernière
  connexion n'est pas un détail — sans elle, un admin qui se connecte chaque semaine pour lire
  ses statistiques sans jamais vendre passerait pour inactif.
- `app:purger-comptes` **ne fait rien sans `--executer`** : elle liste, et prévient. Une purge est
  irréversible et ne demande aucune confirmation ligne à ligne. Aucune tâche planifiée dans
  l'application : c'est à l'établissement qui héberge de décider quand elle tourne.
- **Les super-admins ne sont jamais purgés automatiquement.** Les anonymiser fermerait la porte
  de l'intérieur, un matin, sans que personne ne l'ait demandé.
- Les mentions légales annoncent enfin la durée. Le reste — base légale, registre des
  traitements — y reste marqué « à définir », parce que ça l'est.

### Les conditions d'utilisation

- [cgu.md](docs/specs/cgu.md) : page publique `/conditions-generales`, **incomplète et le
  disant** — éditeur, hébergeur et base légale manquent. Tout le reste décrit le comportement
  réel de l'outil : pas de paiement, pas de mode hors ligne, aucun e-mail, cloisonnement par
  association, journal non modifiable, conservation à trois ans.
- **« À l'inscription » n'existe plus**, puisque personne ne s'inscrit. L'acceptation se joue à
  la **demande d'accès**, par l'adulte responsable de l'association, et à la **première
  connexion** de chaque compte — un vendeur importé depuis un CSV n'a jamais rien accepté.
- Tant que rien n'est accepté, **l'application se résume à cet écran**. La barrière est un
  écouteur sur toutes les requêtes, avec une **liste blanche explicite** : une liste d'interdits
  laisserait passer, par construction, chaque route ajoutée ensuite. La déconnexion reste
  ouverte, et le menu disparaît — ses liens ne feraient que rebondir.
- L'acceptation se demande dans une **modale** — le voile et la boîte de dialogue d'Organic —
  posée à la première ouverture du compte. Elle ne recopie pas les conditions : elle en tire
  **trois points**, ceux qui engagent vraiment un élève. Un mur de texte accepté sans être lu
  n'est pas une acceptation.
- La modale est **servie comme une page**, pas injectée dans l'application : une couche posée
  dans le DOM se referme en supprimant un nœud, et ce qu'elle cachait est déjà chargé. Ici il n'y
  a rien derrière. Aucun JavaScript, et couper JavaScript ne l'ouvre pas.
- **Bug préexistant trouvé au passage** : la route `app_login_redirect` n'existait pas, alors que
  trois contrôleurs y renvoyaient — revenir sur l'accueil une fois connecté produisait une
  erreur. La route existe maintenant, et l'authenticator s'appuie dessus au lieu de dupliquer la
  décision.

### Back-office

- Montants en euros partout (`MoneyField`), libellés accentués, stock épuisé ou négatif signalé,
  tableaux de stats sur deux colonnes au-delà de 1200 px.

### L'accueil public et son pied

- **Photographie du hero** : un stand de lycée, servie en trois largeurs (720 / 1100 / 1600) en
  WebP avec repli JPEG, `srcset` + `sizes`. 5,5 Mo à l'origine, **552 ko pour les six fichiers**,
  et un téléphone en télécharge 36 ko. Dimensions déclarées sur la balise : pas de saut de mise
  en page au chargement.
- Elle passe par le filtre `.washed` du système — désaturée, adoucie, éclaircie — pour se poser
  *dans* la crème plutôt que par-dessus. Angles **inégaux**, deux larges en diagonale et deux
  serrés : un rectangle à rayon constant reste un rectangle. Le grand rayon suit la largeur en
  `clamp()` — fixé à 64 px, il entamait le visage de la vendeuse sur un écran de téléphone.
- Trois dispositions : empilée sous le texte au téléphone, bornée à 560 px sur tablette — sinon
  elle devient une bannière et la scène se lit de trop loin —, et **à droite du texte** au-delà
  de 900 px, dans la mise en page asymétrique que demande le système.
- **Le pied légal est dans le gabarit de base**, pour tout visiteur non connecté, et non page par
  page : une page publique ajoutée demain le portera sans qu'on y pense. Un test parcourt les
  cinq pages et vérifie ses quatre liens.

### Design — « Organic »

- Le design system **Organic** (handoff Claude Design) remplace la piste « Cahier » depuis le
  2026-08-22, sur les **deux** surfaces. Crème `#f5ead8` et sable `#ebddc5`, terre cuite
  `#c67139` pour l'action, sauge `#7a8a5e` comme seconde voix. Caprasimo pour les titres,
  Figtree pour le texte. Sur-arrondi : 32 px sur les conteneurs, pilule sur les contrôles.
- **Une seule feuille de jetons**, [`assets/styles/organic.css`](assets/styles/organic.css),
  importée par le front et par le back. Le bloc Organic y est repris **verbatim** ; en dessous,
  une couche sémantique traduit en vocabulaire Récré. Aucune couleur écrite en dur ailleurs.
- **Deux rôles ont été ajoutés au système** et sont marqués comme tels : un rouge et un ambre.
  Organic n'en a pas — sa terre cuite est déjà l'action et sa sauge le positif —, or une caisse
  doit pouvoir dire « il manque de l'argent » et « ce stock est tombé à zéro ».
- **Trois écarts assumés**, chacun commenté à l'endroit du code : les montants restent en Figtree
  (Caprasimo n'a pas de chiffres tabulaires) ; les cibles tactiles restent à 52 px (Organic en
  dessine 36) ; le libellé du bouton principal est à l'encre et non à la crème, parce que crème
  sur terre cuite ne fait que 3:1.
- Menu **burger** sous 900 px (téléphone et tablette portrait), liens en ligne au-delà.
- Back-office habillé via les variables CSS d'EasyAdmin, **mode clair uniquement**. Ses deux
  rampes grises bleutées sont repeintes à la source : sans ça, du gris-bleu traînait au milieu
  d'une page crème, sur 150 variables dérivées.
- Handoff de référence : `Organic.zip` (`styles.css` + `readme.md`). Maquette antérieure dans
  `docs/design/canvas/`, **caduque** : elle montre la piste « Cahier ».

### Mise en production

- **Même système que LintellO** : on pousse, on `git pull` sur le mutualisé, on build sur place.
  La marche à suivre est dans [`docs/CHECKLIST_PROD.md`](docs/CHECKLIST_PROD.md).
- **`public/.htaccess`** — il manquait, et sans lui rien ne marche sur un mutualisé : en
  développement c'est nginx qui aiguille vers `index.php`, et Apache ne sait rien faire tout
  seul. Vérifié sur un vrai Apache : routes Symfony, assets servis directement, `/index.php`
  redirigé vers `/`, 404 rendus par Symfony, `.env` refusé.
- **Les secrets ont quitté les fichiers suivis.** `.env.dev` portait un `APP_SECRET` en clair
  dans le dépôt ; il vit désormais dans `.env.local`, ignoré, et le fichier suivi ne contient
  qu'un `change_me_in_env_local`. `.env` documente les clés attendues en production, sans aucune
  valeur réelle — la convention de LintellO, qui l'avait apprise en cassant ses clones frais.
- Les assets **ne sont pas versionnés** (`public/build/` est ignoré) : `npm ci && npm run build`
  se lance sur le serveur. Si `npm` n'y est pas, il faudra construire en local et transférer —
  et le refaire à chaque déploiement.

### Outillage

- Base de test `recre_test`, mise en place décrite dans `docs/conventions.md`.
- Fixture de bac à sable, rejouable sans purger la base :
  `php bin/console doctrine:fixtures:load --append --group=asso-test`
  Comptes : `laignelet.david@gmail.com` (admin) et `test.vendeur@recre.local` / `test1234`.

## En cours

- Rien. **La file de chantiers est vide** : tout ce qui pouvait être décidé et codé ici l'a été.

## À faire

**La file complète est dans [`docs/chantiers.md`](docs/chantiers.md)** — 56 actions faites, 0
restante.

### Avant que les mails servent vraiment

- [ ] **Poser la clé Brevo** dans `.env.local`, avec `MAILER_FROM` et `CONTACT_EMAIL`. Trois
      lignes, décrites dans [`docs/CHECKLIST_PROD.md`](docs/CHECKLIST_PROD.md).
- [ ] **Authentifier le domaine chez Brevo (SPF, DKIM).** Sans ça les messages arrivent en
      indésirables — indiscernable, pour celui qui attend son lien, de « rien n'est parti ».
- [ ] **Vérifier de bout en bout** : une demande d'accès qui arrive, un mot de passe oublié qui
      permet de se reconnecter. À faire **avant** d'ouvrir les comptes des élèves.

### Ce qui ne se code pas, et qui bloque toujours une mise en service réelle

- [ ] **L'éditeur et l'hébergeur**, à renseigner dans les mentions légales et les CGU. Les deux
      pages portent le trou et le disent.
- [ ] **La base légale du traitement**, et l'information des représentants légaux des mineurs.
      Savoir combien de temps on garde ne dit pas de quel droit on garde.
- [ ] **L'inscription au registre des traitements** de l'établissement, et probablement un mot au
      délégué à la protection des données de l'académie. **Brevo y figure comme sous-traitant**
      depuis le 2026-08-25 : les adresses des destinataires passent par lui.
- [ ] **Une politique de sauvegarde.** Sans elle, une perte de données est définitive ; avec une
      rétention infinie, elle annule la purge à trois ans en silence.
- [ ] Le **droit à l'effacement exercé avant le terme** : la brique technique existe
      (l'anonymisation), le chemin dans le back-office n'existe pas.

### Priorité basse

- [ ] Manifeste PWA et icône, pour un « ajouter à l'écran d'accueil » propre sur la tablette du
      stand.
- [ ] Maquette `docs/design/canvas/` : entièrement caduque depuis le passage à Organic. Elle
      montre la piste « Cahier », que plus aucun écran ne porte. La refaire ou la retirer.
- [ ] Back-office : le mode sombre d'EasyAdmin n'est pas thématisé. Ses rampes grises sont
      désormais chaudes, mais ses fonds restent ceux du bundle — le sombre jure toujours.
- [ ] Les polices d'Organic viennent de Google Fonts par CDN. Sur une instance sans internet
      sortant, Caprasimo et Figtree ne chargent pas et tout retombe sur `system-ui`.
- [ ] Rendre ALESAT administrable à nouveau : rebasculer un compte admin dessus, ou en créer un.
- [ ] Merger `develop` dans `main` et **pousser**.
