---
suivi: 1650
date: 2026-09-22
sujet: Diagnostic — recalcul serveur à blanc des totaux pendant la saisie (option A)
chantier: Foncier / Faisabilité
type: diagnostic
statut: poussé
hash: cdeee33a
fichiers:
  - docs/suivi/SUIVI_1650_diagnostic_recalcul_direct.md
---

# SUIVI #1650 — recalcul à blanc pendant la saisie

🔴 **Lecture seule.** Aucun fichier applicatif modifié, aucune migration, aucun
test écrit, aucun rebuild Vite, aucune entrée au journal. Les mesures du § 3
sont locales, hors base et hors HTTP. Une requête SQL en `SELECT` pur est
fournie pour l'effectif des utilisateurs ; Robin l'exécute en production s'il
veut un chiffre.

## PROMPT ENVOYÉ

Robin : « Il faudrait que pendant la saisie, ça mette à jour les totaux sans
attendre de sauvegarder. » Arbitrage déjà pris : **option A**, recalcul serveur
à blanc via le moteur PHP existant, sans dupliquer les formules en JavaScript.
Ce lot mesure si c'est réalisable sur OVH mutualisé, et à quel coût. Il ne
tranche pas si c'est une bonne idée.

## BASE GIT ET FICHES LUES

`HEAD` local = `origin/main` = `c220d578` (chore hash fiche #1649) au moment
du diagnostic. `git fetch origin main` sans nouveauté au-delà de #1649.
Le prompt annonçait #1649 « en cours » : il a atterri sur `origin/main`
pendant cette lecture (`57033acf` + hash fiche). Ce diagnostic n'a touché
aucun fichier de #1649.

Fiches consultées (section LEÇON d'abord) :

- **#1645** (`statut: poussé`) — l'assiette n'entre dans aucun calcul ;
  `FaisabiliteInput` à quatre champs ferme la question. Réemployé tel quel au
  § 2. La LEÇON « une table écrite n'est pas une table utilisée » oriente le
  § 1 : ce n'est pas parce que les trois services font du `loadMissing` qu'il
  faut les réutiliser pour un calcul à blanc.
- **#1648** (`statut: poussé`) — enregistrement unique, dirty-only, PUT de
  persistance inchangés. Le recalcul à blanc est un **autre** verbe : il ne
  doit pas réutiliser `saveFiche` / `updateFiche`.
- **#1649** (`statut: poussé`, `57033acf`) — détection dirty au modèle
  (`watch` sur les refs, `Show.vue`). Ce diagnostic ne touche aucun de ces
  fichiers. Tout lot front ultérieur qui brancherait le recalcul reste en
  **série** sur `Show.vue` (règle zones communes).
- **#1636** — inventaire des natures de postes (`saisi` / `saisi_par_lot` /
  `saisi_unitaire_x_nombre` / `forfait` / `calcule`). Réemployé au § 7 pour
  tracer la frontière somme simple vs moteur.
- **#1635** — limite de génération 200 lots ; commentaire L720-725 de
  `Show.vue` : « Un appel par cellule sur 50 lots signifierait 50 requêtes et
  50 recalculs du moteur — inutilisable. » C'est précisément le risque que
  l'anti-rebond du § 4 doit tuer.
- **#1632** — précédent `÷ 1,20` en dur. Confirme l'arbitrage : ne pas
  réécrire les formules en JS. Le moteur lui-même contient encore un
  `Dec::divOrZero($sumRecettesOdf, Dec::of('1.20'))`
  (`FaisabiliteLotissementMoteur.php:746`) : une copie JS reproduirait la
  même dette.
- **#1143 / #1139 / #1144 / #1145** — moteur, étude Fi, fiche, grille. Non
  relues in extenso ; les sources actuelles ont été ouvertes.

Anti-collision : le dernier numéro **committé** sur `origin/main` est 1649.
**1650 est libre.**

Base MySQL locale inaccessible (constat récurrent #1626 → #1649) : aucune
mesure n'a exigé la base.

---

## 1. LE MOTEUR PEUT-IL TOURNER À BLANC ?

### 1.1 Preuve : aucune I/O dans `app/Support/Foncier/Faisabilite/`

Grep exhaustif sur **tout** le dossier (15 fichiers, y compris
`FaisabilitePromotionMoteur.php` qui n'est pas appelé par cet écran) :

| Motif | Occurrences |
|---|---|
| `DB::` | **0** |
| `->query()` / `::query()` | **0** |
| `->save()` / `->update()` / `->create()` / `->delete()` / `->insert()` | **0** |
| `Log::` / `Cache::` / `event(` / `Event::` / `dispatch(` | **0** |
| `->loadMissing` / `->load(` | **0** |
| `use Illuminate\` / `use App\Models\` | **0** |
| `file_put` / `fopen` / `Http::` / `Mail::` / `Storage::` | **0** |
| `Scenario` / `Eloquent` | **0** |

Le dossier n'importe même pas Laravel. Dépendances réelles du moteur
lotissement : `Brick\Math\BigDecimal`, `Dec`, `FoncierReferentiels` (constantes
PHP, `app/Support/Foncier/FoncierReferentiels.php`, aucun façade).

Commentaire d'intention, confirmé par le grep :

- `FaisabiliteLotissementMoteur.php:11-12` — « Sans effet de bord, répétable :
  aucune écriture DB, aucun état persisté. Entrée / sortie 100 % en mémoire. »
- `FaisabiliteInput.php:6` — « Jeu de données EN MÉMOIRE — aucun état persisté. »

**Le moteur n'écrit rien**, même indirectement : pas de log, pas de cache, pas
d'événement, pas de colonne dérivée. Les « signaux » (`tva_marge_negative`,
L73-78) sont des tableaux dans le résultat, pas des side-effects.

### 1.2 Appel depuis un payload front, sans base

`FaisabiliteInput::fromArrays()` (`FaisabiliteInput.php:34-56`) construit déjà
l'entrée depuis quatre tableaux PHP. C'est le chemin des tests unitaires
`FaisabiliteLotissementMoteur1143Test` (fixture, aucun `Model::`).

Les quatre champs (`FaisabiliteInput.php:19-26`) :

| Champ | Source saisie écran | Clés front actuelles |
|---|---|---|
| `parametres` | **pas saisis** sur `Show.vue` | absents du formulaire ; sous-ensemble seulement dans `grille_prix.taux_tva` et `etude_financiere.regles` |
| `ficheLignes` | `ficheRows` / `saveFiche()` L467-480 | suffixe `_m2` (`surface_totale_m2`…) |
| `lots` | `lotRows` / `saveGrille()` L915-949 | mixte : `surface_odf_m2` vs `surface_tva_marge` |
| `etudeFiSaisies` | `etudeSaisies` / `saveEtudeFi()` L1202-1205 | 26 codes `EtudeFiSaisies::codes()` |

Le moteur attend les clés **sans** suffixe `_m2` (`FicheLigneInput::fromArray`
L34-35, `LotInput::fromArray` L45 : `surface_odf` pas `surface_odf_m2`).
La traduction existe déjà côté modèle : `ScenarioFicheLigne::toMoteurArray()`
L155-168 et `ScenarioLot::toMoteurArray()` L172-193. Un calcul à blanc doit
**réutiliser cette traduction** (ou un équivalent statique), pas inventer
d'autres noms.

Les lignes nouvelles ont `id: null` (`duplicateLotRow` L705, génération #1635).
`LotInput::fromArray` L42 fait alors `uniqid('lot_', true)`. Pour réinjecter
TVA / honoraires dans la bonne ligne à l'écran, le front devra poser un
identifiant client stable (index, ou `client_id`) — **note d'implémentation,
non faite ici**.

**Paramètres.** Ils ne sont pas éditables sur cet écran. Les copier depuis le
front serait incomplet (seuls 4 taux/montants sont exposés dans `regles` + 2
taux TVA). Chemin réaliste : **une lecture** `parametres` déjà persistés
(`ScenarioParametre::resolus()`, L79-103, pur mapping d'attributs, zéro requête
supplémentaire une fois le modèle chargé). Ce n'est pas un blanc *strict*
zéro SQL ; c'est un blanc **zéro écriture**, plus l'auth.

### 1.3 Point décisif : contourner les trois `calculerAffichage()`

Les trois services **lisent** la base avant d'appeler le moteur. Aucun n'écrit
pendant le calcul. Aucun ne peut recevoir des données non persistées.

| Service | `loadMissing` | Construction de l'input | Sérialisation |
|---|---|---|---|
| `ScenarioFicheFonciereService::calculerAffichage` L183-256 | `ficheLignes.proprietaire`, `lots`, `parametres` (L185) | modèles → `toMoteurArray()` ; **pas** d'`etudeFiSaisies` (L203-213) | `serializeLigne($ligne Eloquent, $calc)` calé sur `$ligne->id` (L223-228) |
| `ScenarioGrillePrixService::calculerAffichage` L166-217 | idem (L168) | `buildInput()` L222-248, **pas** d'étude Fi | `serializeLot($lot Eloquent, …)` calé sur `$lot->id` (L187-193) |
| `ScenarioEtudeFiService::calculerAffichage` L95-147 | `ficheLignes`, `lots`, `parametres`, `etudeFiPostes` (L97) | les quatre champs, saisies depuis `etudeFiPostes` persistés (L103-113) | sections déjà calculées par le moteur |

Conséquences :

1. Appeler `calculerAffichage` pendant la saisie **écraserait** le brouillon
   front par l'état disque (`loadMissing` recharge les relations persistées).
2. Les lignes `id: null` n'ont pas de modèle : la sérialisation par id
   les perdrait.
3. Fiche et grille **omettent** les saisies étude Fi : un run « fiche seule »
   calcule quand même `calculerEtudeFi` mais avec `EtudeFiSaisies::empty()`
   (`FaisabiliteInput.php:25`). Les totaux étude de l'écran viennent
   exclusivement du troisième appel (`showDirect` L392-394 = **trois** runs
   moteur par chargement de page).

**Tranché : un calcul à blanc doit contourner les trois services.** Méthode
nouvelle, dédiée, qui : (a) construit `FaisabiliteInput::fromArrays` depuis le
payload, (b) appelle `$moteur->calculer($input)` **une fois**, (c) renvoie
`toArray()` filtré par `peut_prix`. Ne pas « extraire » `calculerAffichage` en
lui passant des modèles unsaved.

Précédent JSON sans écriture déjà dans le même contrôleur :
`previewEtudeFiImport()` L281-302 — `response()->json(...)`,
`assertImportable` refuse le gelé, aucune sync.

---

## 2. QUE FAUDRAIT-IL ENVOYER, ET QUE RÉCUPÈRE-T-ON ?

### 2.1 Graphe de dépendance (ordre réel du moteur, L14-17 et L23-119)

```
parametres (taux, plafonds, barème honoraires commerciaux)
        │
lots saisis ──► calculerLotBase (Σ surfaces, Σ prix TTC, €/m² ligne)
        │
        └──► totaux grille : nb H/ODF, surfaces, recettes TTC,
             prix_moyen_m2 (AVERAGEIF), prix_moyen_lot_hectare
             (exclut agricole + TVA totale sociale), recettes ODF HT = TTC÷1,20
                    │
                    ├──────────────────────► prix_m2_global de chaque ligne
                    │                        fiche (L772-783) — indicateur,
                    │                        n'alimente AUCUN autre calcul
fiche saisie ───────┴──► totaux fiche + ventilation par régime TVA
                    │
                    ▼
frais_notaire = (Σ prix_achat_numeraire + total_recettes_odf_ttc) × taux
base_PR_foncier = Σ prix_tva_sur_marge + Σ prix_tva_totale + frais_notaire
                  (sans TVA exclu — L49-54)
PR/m² = base_PR / surface_cessible_hors_agricole
                    │
                    ▼
TVA par lot (dépend PR/m² + taux normal/social)
honoraires montage/gestion par lot (taux, plafond, ou surface ODF × prix_moyen)
                    │
étude Fi saisies ───┴──► × nb_lots, imprévus, bancaires, bénéfice,
                         résultat consolidé, sous-totaux de section
```

**Inverse, ce qui NE remonte PAS :**

- Une saisie étude Fi **ne change pas** la fiche ni la grille (TVA / honoraires
  des lots sont calculés *avant* `calculerEtudeFi`, L107-119).
- Une saisie fiche **change** : totaux fiche, notaire, PR, TVA lots, honoraires
  lots, **toute** l'étude Fi (achat, marge, bénéfice, consolidé).
- Une saisie grille **change** : totaux grille, `prix_m2_global` fiche
  (indicateur), notaire (via recettes ODF), PR, TVA, honoraires, **toute**
  l'étude Fi.
- Identité / commentaire : hors `FaisabiliteInput`. Aucun total.
- **Assiette : hors graphe.** Confirmé #1645 (grep `parcelle` / `surface_retenue`
  / `hypothetique` / `surfaceAssiette` sur `app/Support/Foncier/Faisabilite/`
  → 0). Re-vérifié pour ce lot : **0 occurrence**. `assietteTotalSurface`
  (`Show.vue:240-242`) est déjà une somme client, indépendante du moteur.
  **Exclure l'assiette du payload et de la réponse.**

### 2.2 Faut-il renvoyer TOUT l'état ?

**Oui, les trois sections calculables.** Pas l'assiette, pas l'identité.

Raison : la barre KPI (`kpisPrincipaux` / `kpisContexte`, `Show.vue:1454-1487`)
mélange `eTot` (étude), `gTot` (grille) et `basePrixRevient` (fiche). Une
frappe dans n'importe laquelle des trois doit rafraîchir les trois si on
veut que la barre suive. Envoyer « la section courante seulement » et
recharger le reste depuis la base **reviendrait à mélanger brouillon et
disque** — exactement le bug que le blanc veut éviter.

Les paramètres restent ceux du disque (non édités ici).

### 2.3 Taille mesurée (10 lignes de fiche, N lots, 26 postes étude Fi)

Banc local, `json_encode` UTF-8, même jeu que le § 3 :

| N lots | Payload (entrée moteur) | Résultat `toArray()` |
|---|---|---|
| 6 | 6 059 o | 24 080 o |
| 50 | **25 556 o (~25 Ko)** | **56 536 o (~55 Ko)** |
| 200 | 92 277 o (~90 Ko) | 170 228 o (~166 Ko) |

Le payload HTTP réel sera un peu plus gros (clés `_m2`, `seq`, enveloppe).
Ordre de grandeur **30 Ko / 60 Ko** pour le cas « 50 lots + 10 lignes »
annoncé. Négligeable devant `post_max_size` web (voir #1229). Ce n'est pas
le point qui tue l'option A.

---

## 3. COMBIEN DE TEMPS ÇA PREND ?

### 3.1 Moteur seul — mesuré localement

Conditions (brutes) :

- PHP 8.3.1, SAPI `cli`
- `vendor/autoload.php` uniquement — **pas** de kernel Laravel, **pas** de
  base, **pas** d'HTTP
- `FaisabiliteLotissementMoteur::calculer(FaisabiliteInput::fromArrays(...))`
- 10 lignes de fiche, 26 postes étude Fi (fixture #1143), N lots
- 3 runs de chauffe + 30 mesures `hrtime`, temps en millisecondes

| N lots | min | p50 | p95 | max | avg |
|---|---|---|---|---|---|
| 6 (cas APS1 demandé) | 0,81 | **0,87** | 0,97 | 1,15 | 0,88 |
| 50 | 3,13 | **3,56** | 5,21 | 5,38 | 3,82 |
| 200 (limite #1635) | 10,51 | **11,52** | 14,57 | 15,46 | 11,88 |
| Fixture native 2×3 | 0,43 | 0,61 | 0,86 | 0,88 | 0,63 |

Le moteur est **quasi linéaire** avec le nombre de lots (~0,06 ms/lot sur
cette machine). À 200 lots il reste sous **16 ms** au pire des 30 runs.

### 3.2 Aller-retour production — estimé, ce qui domine

Mesures locales **hors FPM**, pour cadrer :

| Étape | Mesure | Statut |
|---|---|---|
| Moteur 50 lots | 3,6 ms p50 | **mesuré** |
| Moteur 200 lots | 11,5 ms p50 | **mesuré** |
| `vendor/autoload.php` + `bootstrap/app.php` CLI à froid | 228 ms (autoload 194 + app 35) | **mesuré**, pas le web |
| `php artisan --version` (kernel console complet) | 1 007 ms | **mesuré**, *pire* borne, pas le web |
| HTTP + session + middleware `auth` / `foncier.lecture` / `foncier.module` | — | **NON ÉTABLI** en prod |
| JSON encode 55–166 Ko | < 1 ms typique | hypothèse raisonnable, non chronométrée à part |

**Ce qui domine : le bootstrap Laravel + le round-trip HTTP, pas le moteur.**
Même à 200 lots, le moteur est < 5 % d'un aller-retour si le bootstrap web
dépasse ~250 ms, et < 20 % s'il est à 80 ms (opcache chaud — **hypothèse
non mesurée sur cluster113**).

Pour mesurer le bootstrap **web** (SAPI `fpm-fcgi`, règle #1229), coller en
prod dans l'onglet Réseau du navigateur, connecté, l'appel JSON déjà existant
et léger :

`GET /foncier/scenarios/parcelles/search?q=a`

(`ScenarioFaisabiliteController::searchParcelles`, L475-524 — auth + une
requête SQL + JSON, **zéro moteur**). Le `Waiting (TTFB)` de cette requête
est la borne inférieure d'un recalcul à blanc. Le surplus du recalcul sera
le parse du payload + 1–12 ms de moteur + encode de 25–170 Ko.

`QUEUE_CONNECTION=sync` et `max_execution_time = 300 s` (`docs/deploy/ovh-php-config.md`,
`PROJECT.md`) sont hors sujet ici : on n'enqueue rien, on tient 15 ms de CPU
moteur dans une requête qui a 300 s devant elle.

---

## 4. CONTRAINTES OVH MUTUALISÉ

### 4.1 Une requête par frappe est exclue

C'était déjà écrit en 1635 (`Show.vue:720-725`). Confirmé par le graphe : chaque
frappe utile déclencherait sinon un POST + bootstrap. **Anti-rebond
obligatoire.**

**Délai recommandé : 300 ms** après la dernière modification du modèle.

Justification, pas une valeur au hasard :

- Le moteur (0,9–12 ms) **n'impose aucun** délai : on ne debounce pas pour le
  CPU, on debounce pour collapser les frappes d'un nombre (4 à 8 digits).
- Précédents **déjà dans l'ERP** : `ParcelleFicheNavigation.vue:100` → **280 ms**
  (recherche parcelles) ; `Annuaire/Index.vue:73` et
  `Admin/FacturesSuperadmin/Index.vue:42` → **350 ms**. 300 ms est le milieu
  de cette fourchette, sur le même geste « l'utilisateur a fini de taper ».
- En dessous de ~200 ms on rattrape encore les digits isolés d'un
  `InputNumber`. Au-dessus de ~500 ms le tableur « ne suit plus » au sens
  de Robin.

Après la dernière frappe : 300 ms + TTFB (dominé par Laravel, § 3.2). Le
total perçu sera de l'ordre de **0,5–1 s**. Acceptable pour des KPI, pas pour
la cellule en cours (la cellule n'a pas à attendre : c'est la valeur saisie).

### 4.2 Réponses dans le désordre

Risque réel : debounce 300 ms, requête A part, l'utilisateur retape, requête B
part avant qu'A ne revienne. Si A arrive après B, l'écran affiche un **total
périmé** plus ancien que la saisie visible.

Évitement (les deux, pas l'un ou l'autre) :

1. **Annulation** de la requête précédente (`AbortController` sur `fetch` ;
   Inertia n'est **pas** le bon véhicule — trop lourd, et `router.reload`
   rechargerait le persisté).
2. **Jeton de séquence** monotone : le front incrémente `seq` à chaque envoi ;
   le serveur le renvoie tel quel ; le front ignore toute réponse dont
   `seq < dernierSeqAppliqué`. L'abort seul ne suffit pas (une réponse déjà
   en vol peut gagner la course).

### 4.3 Saturation multi-utilisateurs

Les scénarios de faisabilité sont un outil **équipe foncier** (`GS_HECTARE`
porte `foncier.lecture` / `gestion` / `prix`, migration
`2026_07_30_220000_grant_foncier_permissions_to_gs_hectare.php`), pas tout
l'ERP.

**Effectif exact : NON ÉTABLI** (base locale inaccessible). Requête lecture
seule à coller en prod :

```sql
SELECT COUNT(*) AS n_gs_hectare
FROM users
WHERE m365_groups LIKE '%GS_HECTARE%';
```

Ordre de grandeur **hypothèse** (étiquette) : une poignée de personnes, pas
des dizaines simultanées sur le même écran. Pire cas théorique si 5
saisisseurs tapent sans pause à 300 ms : ~16 req/s, chacune occupant FPM
le temps du bootstrap (centaines de ms), pas des 12 ms de moteur. Sur
mutualisé, le quota de processus PHP est la vraie limite, pas le CPU du
moteur. Le debounce + le fait que personne ne tape en continu ramène ça à
**1–2 workers** dans l'usage réel.

Rien à enqueue (`QUEUE_CONNECTION=sync` n'entre pas en jeu).

### 4.4 Route allégée vs bootstrap complet

Une route **sans** middleware d'auth est un trou. Le bootstrap Laravel
complet (session, `feature.permission:foncier.lecture`, `foncier.module`,
Gate) est **acceptable** : c'est déjà ce que font `searchParcelles` et
`previewEtudeFiImport`.

Ce qu'il faut alléger, ce n'est pas Laravel, c'est **Inertia** : ne pas
rejouer `showDirect` (3× moteur + documents + historique + parcelles).
Réponse **JSON** du seul `FaisabiliteResultat`. C'est la différence de
coût utile.

---

## 5. QUELLE ROUTE, ET QUELS DROITS ?

### 5.1 Forme proposée (rien n'est créé)

| | |
|---|---|
| Verbe | `POST` (calcul, pas d'idempotence à garantir comme un PUT de sync) |
| Chemin | `/foncier/dossiers/{dossierFoncier}/scenarios/{scenarioFaisabilite}/recalcul` |
| Nom | `foncier.dossiers.scenarios.recalcul` |
| Middleware groupe | déjà `foncier.lecture` + `foncier.module` (`web.php:803-805`) |
| Middleware extra | `feature.permission:foncier.gestion` — **même cran** que `updateFiche` (`web.php:1275-1277`) |
| Réponse | `JsonResponse` — **pas** `Inertia::render`, **pas** `redirect()->back()` |

Payload (liste blanche, calquée sur les trois PUT existants) :

```
seq: integer
lignes: array  — mêmes clés que updateFiche (L183-196)
lots: array    — mêmes clés que updateGrille (L222-241)
postes: object — mêmes 26 codes que updateEtudeFi (L265-268)
```

Pas de `parcelles`. Pas de `libelle` / `mode` / `commentaire`. Pas de
paramètres (lus en base).

Réponse :

```
seq
peut_prix
fiche: { lignes: [champs calculés + id/client_id], totaux, base_prix_revient_foncier, frais_notaire, prix_revient_foncier_m2 }
grille: { lots: [champs calculés], totaux, intermediaires, signaux }
etude: { sections, totaux }     — vide si !peut_prix, comme calculerAffichage L117-124
```

Filtrage `peut_prix` : **absent du JSON**, pas masqué à l'affichage (règle
sécurité-données). Réutiliser la logique `stripPrixKeys` des services, pas
une nouvelle liste noire.

### 5.2 Garantir que ça ne persiste rien

- **Méthode de service nouvelle** (`calculerABlanc` ou équivalent), pas un
  paramètre `bool $persister` sur `updateFiche` / `syncLignes`.
- Interdit dans cette méthode : `save`, `update`, `create`, `delete`, `sync`,
  `DB::transaction`, `FieldAuditService`.
- Ne pas passer par `syncLignes` / `syncLots` / `syncSaisies` « puis rollback »
  — MySQL committe le DDL, et un rollback de DML resterait une écriture
  visible un instant ; hors sujet, trop fragile.
- Test source (lot d'implémentation, pas celui-ci) : la méthode ne contient
  aucun de ces tokens ; le contrôleur d'action non plus.

### 5.3 Droits

Chaîne minimale, calquée sur `updateFiche` L175-181 **plus** `view` :

1. `authorize('view', $dossier)` — ACL dossier
   (`DossierFoncierPolicy::view` → `userCanViewRecord`,
   `HasRecordHabilitations.php:56-69`). Un scénario d'un dossier que
   l'utilisateur ne voit pas → 403.
2. `authorize('update', $dossier)` — c'est un geste de saisie, pas de lecture.
3. `assertScenarioDuDossier` (L788-791).
4. `userHasPermission(..., 'foncier.prix')` — les montants. Sans ce droit :
   403, **pas** un JSON de surfaces seules. Un lecteur sans prix n'a rien
   à recalculer pendant une saisie qu'il ne peut pas faire
   (`peutEditerFiche = peutEditer && peut_prix`, `Show.vue:73-75`).
5. `assertNonGele` — voir § 5.4.

🔴 `Gate::before` (`AppServiceProvider.php:69-74`) renvoie `true` si
`isAdminERP()` (groupe `GS_ADMIN_ERP`, `User.php:113`). **Le compte de Robin
ne prouve aucun refus.** Les tests d'implémentation devront utiliser un
utilisateur `GS_HECTARE` **sans** `GS_ADMIN_ERP`. Vérification manuelle du
403 : un compte lecture seule, pas Robin.

### 5.4 Scénario gelé — tranché : refuser

`assertNonGele` (`ScenarioFaisabiliteController.php:781-785`) abort 403
« gelé (programme issu) ». Le front pose `peutEditer = peut_gestion && !gele`
(`Show.vue:71-72`) : **aucune saisie, donc aucun appel légitime**.

Répondre quand même (what-if sur un gelé) créerait un second mode d'emploi
non demandé, et un vecteur pour envoyer un payload qui n'est plus le
persisté. **403, comme les PUT.** Le gelé reste lisible via `showDirect`,
totaux persistés.

---

## 6. CE QU'ON AFFICHE PENDANT L'ATTENTE — proposition à Robin

Entre la frappe et la réponse, les totaux **moteur** à l'écran sont ceux de
la dernière réponse (ou du chargement). Ils sont périmés.

**Aucun précédent ERP** de « total en cours de recalcul ». Les `opacity-50`
trouvés sont des disabled de boutons, pas un état stale. `MesTaches` fait de
l'UI optimiste sur d'autres champs — ici l'optimiste **exigerait** les
formules JS, explicitement écartées.

Proposition :

- **Ne pas vider** les totaux (saut visuel vers « — », pire que périmé).
- **Estomper** la barre KPI collante et les lignes TOTAUX / sous-totaux
  moteur (`opacity-60`) dès que `seq` envoyé > `seq` appliqué.
- **Indicateur discret** : un point ou « … » à côté de « Résultat consolidé »,
  pas un overlay, pas un spinner plein écran (ça casserait le tableur).
- Les **sommes simples** du § 7, si le lot léger est retenu, restent
  opaques et à jour : elles n'attendent pas le serveur.
- La cellule en cours de saisie n'est jamais estompée.

Robin tranche : estompage / point discret / les deux / ne rien faire (totaux
figés jusqu'à la réponse, comme aujourd'hui mais plus court).

---

## 7. PÉRIMÈTRE RÉALISTE — sommes simples vs moteur

Frontière : **additionner (ou compter) des valeurs déjà affichées dans la
même grille n'est pas dupliquer une formule métier.** Appliquer un taux, un
diviseur, un AVERAGEIF, un plafond, un régime, un prorata, un × nombre de
lots, **si**.

### 7.1 Groupe « somme simple » — candidat front immédiat

| Valeur à l'écran | Source actuelle | Pourquoi c'est une somme |
|---|---|---|
| Assiette TOTAL | déjà client `assietteTotalSurface` `Show.vue:240-242` | déjà fait (#1641) |
| Fiche TOTAUX `sum_surface_totale` / `_achetee` / `_odf` / `sum_nb_lots_odf` | `tot()` ← `props.fiche_fonciere.totaux` L516-518, pied L2137-2140 | Σ des cellules saisies de la colonne |
| Fiche TOTAUX `sum_prix_achat_numeraire` / `sum_prix_achat_odf_ttc` | idem L2141-2142 | idem |
| Fiche ligne `prix_achat_total` | moteur L767 puis copie figée L389 | **a + b** sur la même ligne (`numeraire` + `odf`) |
| Fiche TOTAUX `sum_prix_achat_total` | totaux moteur L846 | Σ de la colonne précédente |
| Grille ligne `surface_cessible` | moteur L634-640 | Σ des 5 surfaces de la ligne |
| Grille ligne `prix_lot_ttc` | moteur L641-647 | Σ des 5 prix de la ligne |
| Grille `nb_lots_hectare` / `nb_lots_odf` | moteur L694-699 | comptage des cases à cocher |
| Étude Fi `TOTAL TAXES` | sous-total L209, nature `saisi` L379-384 | Σ de deux montants saisis affichés tels quels |
| Étude Fi postes `nature=saisi` ou `forfait` (commission, travaux divers, avocat, SPS, huissier, …) | montant = saisie | le montant **affiché** est la saisie ; pas de × N |

### 7.2 Groupe « moteur » — interdit côté front

| Valeur | Pourquoi ce n'est pas une somme | Source |
|---|---|---|
| Ventilations fiche `sum_prix_tva_*` / `sum_surface_tva_*` | **convention de régime** : aiguillage du total vers un seau | L785-810, L847-852 |
| `prix_m2_surface_achetee` / `prix_m2_global` | division ; le second **croise** `prix_moyen_m2` grille | L770-783 |
| `mode_prix_affiche` HT/TTC | convention `assujetti_tva` | L768 |
| `frais_notaire`, `base_prix_revient_foncier`, `prix_revient_foncier_m2` | taux notaire + exclusion sans TVA + prorata surface | L42-60 |
| `prix_moyen_m2` | AVERAGEIF + garde recettes=0 | L719-733 |
| `prix_moyen_lot_hectare` | **exclut** agricole et TVA totale sociale | L737-744 |
| `total_recettes_odf_ht` | **÷ 1,20 en dur** | L746 |
| TVA lot / `montant_tva_lot` / `tva_marge_negative` | taux, PR/m², formule marge | L864-918 |
| Honoraires montage/gestion | taux, plafond, branche ODF | L925-971 |
| Étude Fi `saisi_par_lot` (12 postes VRD/BE) | **× nb lots total** | L211-214, L235-242 |
| Compteurs | unitaire × nombre | L215 |
| Imprévus travaux | **taux** × 4 lots (pas compteurs) | L216-218 |
| Honoraires commerciaux, communication, impôts fonciers | barème ou × nb Hectare | L101-105, L259-266 |
| Frais bancaires | **taux** × recettes Hectare TTC | L265 |
| Marge, bénéfice, % CA, consolidé, CA HT reconstitué | enchaînement complet | L318-344 |
| Sous-totaux VRD / honoraires externes / internes / PR | mélange des lignes ci-dessus | L409, L429, L452, L468 |
| KPI barre `Résultat consolidé`, `Bénéfice brut`, `Écart PR`, `PR total`, `Total TVA` | moteurs | `Show.vue:1454-1487` |

### 7.3 Est-ce que les sommes seules couvrent l'essentiel du besoin ?

**Franchement : non, pas l'essentiel de CE que Robin regarde, oui une partie
très visible du geste tableur.**

Ce qui « ne bouge pas » aujourd'hui et qui choque dans une grille : le pied
TOTAUX de la fiche (surfaces, prix saisis) et, dans une moindre mesure, le
`prix_achat_total` de la ligne. Ça, les sommes front le règlent **sans
aucun serveur**, sans risque #1632, sans debounce.

Ce que la page met en **premier** sous les yeux, c'est la barre KPI
(`Résultat consolidé`, `Bénéfice brut` — `kpisPrincipaux` L1454-1466). Ça,
c'est 100 % moteur. Les sous-totaux étude Fi « × N lots » et la TVA de
grille aussi. Un lot « sommes seulement » laisserait la barre figée jusqu'à
Enregistrer : le grief « ça ne suit pas » resterait pour la décision go/no-go.

Donc : les sommes simples sont un **lot utile et sans risque**, pas un
substitut de l'option A. Robin a tranché A pour les grandeurs dérivées.
Les sommes peuvent **précéder ou accompagner** A (elles rendent l'attente
300 ms acceptable : le pied de colonne bouge tout de suite, les KPI
s'estompent puis se mettent à jour).

---

## 8. CONCLUSION

**(b) réalisable avec des réserves, chiffrées.**

Pas (a) : on ne peut pas brancher `calculerAffichage` tel quel sur le
brouillon, ni frapper à chaque keydown, ni se passer de jeton de séquence.

Pas (c) : le moteur est à blanc (preuve § 1), 12 ms à 200 lots, payload
25 Ko / 55 Ko à 50 lots. OVH mutualisé ne tue pas l'option A dès lors qu'on
debounce et qu'on répond en JSON. `max_execution_time = 300 s` et
`QUEUE_CONNECTION=sync` sont hors de portée de ce geste.

Réserves qui **doivent** figurer dans le lot d'implémentation :

1. **Contourner** les trois `calculerAffichage` — méthode dédiée, zéro
   `save`/`sync`/`transaction`/`audit`.
2. **Anti-rebond 300 ms** + `AbortController` + **jeton `seq`**.
3. Payload = **fiche + grille + étude Fi** (pas l'assiette). Adapter les
   clés `_m2`. Identifiants client pour les lignes `id: null`.
4. Paramètres lus en base (1 `load` scénario + `parametres`). Auth +
   `foncier.prix` + `update` dossier + **403 si gelé**. Tests de refus sur
   un non-`GS_ADMIN_ERP`.
5. Réponse JSON, pas Inertia. Un seul run moteur (aujourd'hui `showDirect`
   en fait trois).
6. Front en **série** sur `Show.vue` (dernier occupant : #1649, poussé).
7. Mode `promotion` : l'écran appelle **aujourd'hui** le moteur lotissement
   (les trois services n'instancient pas `FaisabilitePromotionMoteur`). Le
   blanc doit suivre le même moteur que `showDirect`. Si un lot futur
   aiguille par `mode`, le recalcul devra suivre — hors périmètre.

### Découpage proposé (soumis à Robin, rien n'est implémenté)

| Lot | Périmètre | Risque | Dépend de |
|---|---|---|---|
| **α — Sommes front** | Pied TOTAUX fiche (colonnes saisies + `prix_achat_total` ligne) ; optionnellement `surface_cessible` / `prix_lot_ttc` ligne grille. Zéro taux. | **Nul** sur les formules. Zone commune `Show.vue` (série après #1649). | — |
| **β — Endpoint à blanc** | Route POST JSON + méthode dédiée + mapping clés + `peut_prix` + 403 gelé/ACL. Tests source « aucun save ». Pas de Vue. | Fuite de persistance si on réutilise un `update*` — évitable par méthode neuve. Payload mal mappé → totaux faux (invisible au lint). | aucun front |
| **γ — Branchement saisie** | Debounce 300 ms, `seq`, abort, application des totaux/KPI/colonnes calculées, estompage § 6. | Course de requêtes mal gérée → total périmé (le bug que `seq` doit tuer). Zone commune `Show.vue`. | β ; série `Show.vue` |
| **δ — (optionnel) mesures prod** | Robin colle le `GET search?q=a` dans l'onglet Réseau + le `SELECT` GS_HECTARE. Ajuste le debounce si TTFB >> 500 ms. | Aucun code. | — |

Recommandation : **β peut démarrer tout de suite** (PHP pur, pas de
`Show.vue`). **α + γ** en série sur `Show.vue` (dernier occupant #1649),
α d'abord si on veut un gain visible sans attendre l'aller-retour. γ est
le vrai lot « option A ».

Hors périmètre noté, non corrigé : le `÷ 1,20` du moteur L746 (dette sœur de
#1632) — le blanc le **réutilisera**, c'est le contrat de l'option A.

---

## DÉPLOIEMENT / TEST

Aucun déploiement. Aucun fichier applicatif. Aucune entrée de journal.

La requête SQL du § 4.3 et le timing Network du `search?q=a` sont en lecture
seule, à exécuter par Robin s'il veut chiffrer l'effectif et le TTFB web.

## LEÇON

1. **`calculerAffichage` n'est pas le moteur.** C'est un chargeur Eloquent +
   une sérialisation calée sur des id persistés. Un blanc qui réutilise ces
   méthodes recalcule le disque, pas la saisie. Le moteur, lui, est déjà à
   blanc depuis #1143 (`FaisabiliteInput::fromArrays`).
2. **Le coût OVH n'est pas le CPU du moteur** (12 ms à 200 lots). C'est le
   bootstrap Laravel à chaque POST. L'anti-rebond existe pour ça, pas pour
   Brick\Math. Le commentaire #1635 L720-725 avait raison sur « 50 requêtes »,
   tort s'il était lu comme « le moteur est trop lourd ».
3. **Additionner une colonne n'est pas une formule métier** ; `÷ 1,20` et
   `× nb lots` en sont. La frontière se lit dans la `nature` déjà étiquetée
   par le moteur (#1636), pas dans le libellé « totaux » de l'écran.
4. **`Show.vue` reste une zone sérielle.** #1649 vient de s'y poser
   (`57033acf`) : tout branchement front du blanc (lots α et γ) se rebase
   dessus. L'endpoint PHP (lot β) peut partir en parallèle.
