---
suivi: 1642
date: 2026-09-22
sujet: Grille de prix du scénario en vrai tableur (bordures jointives, gouttière, bande de KPI)
chantier: Foncier / Faisabilité
type: refonte-affichage
statut: poussé
hash: aba81b02
fichiers:
  - resources/js/Pages/Foncier/Scenarios/Show.vue
  - tests/Unit/ScenarioGrilleTableur1642SourceTest.php
  - tests/Unit/ScenarioGrilleInlineTva1635SourceTest.php
  - tests/Unit/ScenarioHautDePageCompact1641SourceTest.php
  - database/data/journal_mises_a_jour_post_2026_06_12.php
---

# PROMPT ENVOYÉ

Deuxième des trois lots de refonte de `/foncier/scenarios/{id}`. Ne traite QUE la section
GRILLE DE PRIX : vraie grille (bordures jointives 1 px, lignes 27 px, champs sans bordure
propre), gouttière de numéros de ligne, cellule active encadrée, bande de KPI de 30 px à la
place des huit tuiles, Tranche et flag H remontés dans la grille, ligne de totaux, barre
d'outils sur une ligne. Aucun calcul modifié ; le TOTAL TVA doit être identique au centime.

# SYNTHÈSE

## Prérequis vérifié

`git fetch origin` puis `git log --oneline -1 origin/main` → **`21c919d2 chore(suivi): hash
fiche #1641`**. `HEAD..origin/main` VIDE et `git branch -r --contains f1548f6e` retourne
`origin/main` : le #1641 est bien sur `origin/main`. Rebase donc sans objet (arbre déjà
identique) — **hash de base : `21c919d2`**. Le build Vite a été lancé après cette vérification.

## Ce qui a changé visuellement

| Point | Avant | Après |
|---|---|---|
| Aspect général | table `min-w-full text-sm`, lignes séparées par `border-b border-[#F3F4F6]`, cellules `px-2 py-1` — chaque champ rendait sa propre boîte arrondie | `w-full border-collapse`, **bordure 1 px `#D1D5DB` sur CHAQUE cellule**, lignes `h-[27px]`, aucun rayon d'angle |
| Champs de saisie | `w-24` / `w-28` / `w-32` avec bordure, fond et padding PrimeVue | `CELL_INPUT` : `!h-[25px] !w-full !rounded-none !border-0 !bg-transparent !px-1 !py-0 !shadow-none focus:!ring-0` — la bordure de la cellule EST la grille |
| Selects | conteneur PrimeVue bordé (`w-36 !text-sm`) | `CELL_SELECT_PT` : root sans bordure ni fond, label `!leading-[25px]`, flèche réduite à `!w-4` |
| Cellule active | rien | `CELL_FOCUS` : `focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-[#2563EB]` — cadre bleu **dessiné à l'intérieur** de la cellule, la grille ne bouge pas d'un pixel |
| Numéros de ligne | colonne `#` ordinaire | **gouttière de 30 px**, fond `#F3F4F6`, centrée, non éditable, infobulle « Ligne N — numéro d'ordre (calculé) » |
| KPI | **8 tuiles** en grille 4 colonnes (2 rangées) + un encart séparé « Prix de revient foncier au m² » | **UNE bande de 30 px**, 6 chiffres séparés par un filet vertical |
| Tranche | dans le panneau « Détail » | **colonne de la grille** |
| Flag Hectare | case à cocher dans « Détail » | **colonne de la grille** (34 px, centrée) |
| Colonne « Flags » | H + ODF + TVA − | renommée **ODF** (H a sa colonne), garde les pastilles ODF et « TVA − » |
| Totaux | aucun | **ligne `tfoot` grisée** (`bg-[#E5E7EB]`) |
| Barre d'outils | titre + paragraphe de 3 lignes, puis 4 boutons sur une 2ᵉ rangée | **une seule ligne** : titre, « ? », compteur de lots, champ Générer, Générer, + Lot, Enregistrer la grille |
| Textes d'aide | pavé de 6 lignes sous les tuiles | `HelpHint` « ? » + **légende d'une ligne** en pied de grille |
| Section | `rounded-lg … p-4 shadow-sm` (carte) | `rounded border …` sans ombre ni padding : les en-têtes portent leurs propres filets |
| Lecture seule | affichait la valeur brute du type (`terrain_a_batir`) | `typeLotLabel()` affiche le libellé (« Terrain à bâtir ») |

### Gouttière et colonne `#` — arbitrage

Le mockup demande une gouttière de numéros de ligne (point 2) ET conserve `#` parmi les
colonnes calculées (point 4). Or dans cette grille les deux portent **la même valeur** :
`row.numero` est maintenu égal à la position par `emptyLotRow()`, `addLotRow()` (`length + 1`),
`removeLotRow()` et `duplicateLotRow()` (`forEach((r, i) => { r.numero = i + 1; })`).
Deux colonnes côte à côte affichant le même entier n'auraient apporté aucune information.

Retenu : **la gouttière EST la colonne `#`** — 30 px, fond gris, non éditable, en-tête `#`,
infobulle « Calculé » conservée. Elle affiche `idx + 1`, c'est-à-dire la position absolue dans
la grille (donc juste même si `row.numero` divergeait un jour), et l'infobulle rappelle qu'il
s'agit du numéro d'ordre calculé. Une colonne économisée, rien de perdu.

## Bande de KPI — aucune valeur perdue

Les 6 chiffres de la bande : Lots H / ODF · Surf. cessible · Prix moy. / m² · Recettes H TTC ·
Total TVA · PR foncier / m².

Les 4 indicateurs des anciennes tuiles qui ne sont PAS dans la bande — prix moyen lot Hectare,
recettes ODF HT, honoraires de montage, honoraires de gestion — **ne disparaissent pas** :
ils sont déjà dans la barre KPI collante du #1640 (rangée « contexte »), et les honoraires
figurent en plus dans la nouvelle ligne de totaux. Vérifié par test.

## Ligne de totaux — cohérence au centime, et ses limites

La ligne `tfoot` et la bande de KPI lisent **la même source** : `gTot('…')`, c'est-à-dire
`grille_prix.totaux` produit par le moteur. Aucun recalcul côté page (test : pas de
`lotRows.value.reduce(` dans la section). La cohérence au centime est donc **garantie par
construction**, pas par coïncidence.

🔴 EN REVANCHE — À DIRE PLUTÔT QU'À FORCER. Deux totaux n'ont pas le même périmètre que la
somme visible de leur colonne :

| Total affiché | Source | Pourquoi ce n'est PAS la somme de la colonne |
|---|---|---|
| Surface cessible | `surface_cessible_hors_agricole` | exclut les lots en régime agricole, que la colonne « Surf. cessible » affiche pourtant ligne par ligne |
| Prix TTC | `total_recettes_lots_hectare_ttc` | ne somme que les lots **Hectare** ; les prix des lots ODF apparaissent dans la colonne mais pas dans ce total (ils sont dans « Recettes ODF HT », visible dans la barre KPI du #1640) |

Ces écarts sont ceux du moteur, pas de l'affichage : additionner la colonne à l'écran pour
« faire coller » aurait fabriqué un chiffre qui n'existe nulle part dans le calcul. Chaque
cellule de total porte donc une infobulle qui nomme son périmètre exact (« Surface cessible
hors agricole (moteur) », « Recettes des lots Hectare TTC (moteur) »). **Point à confirmer par
Robin** : soit on garde le périmètre moteur (retenu ici), soit on ajoute des totaux de colonne
bruts, ce qui demanderait de les exposer au payload.

## TABLEAU DE MAPPING DES COLONNES

| # | En-tête affiché | Champ du modèle (`lotRows`) | Saisi / Calculé | Colonne(s) `scenario_lots` écrite(s) |
|---|---|---|---|---|
| 1 | `#` (gouttière) | `idx + 1` (position), infobulle `row.numero` | **Calculé** | aucune — non persisté |
| 2 | Lot | `numero_lot_commercial` | Saisi | `numero_lot_commercial` |
| 3 | Type | `type_lot` | Saisi | `type_lot` |
| 4 | Tranche | `tranche` | Saisi (**remonté du Détail**) | `tranche` |
| 5 | H | `est_lot_hectare` | Saisi (**remonté du Détail**) | `est_lot_hectare` |
| 6 | Mode TVA | `lotMode(row)` → `onModeChange` | Saisi (dérivé) | `regime_principal` (via `regimeKeyPour(mode, taux)`) |
| 7 | Taux | `lotTaux(row)` → `onTauxChange` | Saisi (dérivé) | `regime_principal` (idem) |
| 8 | Surf. cessible | `row[regimeDef(regime_principal).surface]` | Saisi | UNE des 5 : `surface_tva_marge`, `surface_tva_marge_social`, `surface_tva_totale`, `surface_tva_totale_social`, `surface_agricole` |
| 9 | Prix TTC | `row[regimeDef(regime_principal).prix]` | Saisi | UNE des 5 : `prix_ttc_tva_marge`, `prix_ttc_tva_marge_social`, `prix_ttc_tva_totale`, `prix_ttc_tva_totale_social`, `prix_ttc_agricole` |
| 10 | € / m² | `prix_m2` | **Calculé (moteur)** | aucune |
| 11 | TVA | `montant_tva_lot` | **Calculé (moteur)** | aucune |
| 12 | Hon. mont. | `honoraires_montage_lot` | **Calculé (moteur)** | aucune |
| 13 | Hon. gest. | `honoraires_gestion_lot` | **Calculé (moteur)** | aucune |
| 14 | ODF | `est_lot_odf`, `tva_marge_negative` | pastilles : ODF saisi dans « Détail », TVA − calculée | `est_lot_odf` |
| 15 | (actions) | Détail / Dupl. / Suppr. | — | — |

Colonnes 9 à 13 masquées sans `peut_prix` : 15 colonnes avec le droit prix, 10 sans
(`:colspan="peut_prix ? 15 : 10"`).

### Mode TVA × Taux → régime — CONFIRMÉ INCHANGÉ (#1635)

| Mode affiché | Taux affiché | `regime_principal` stocké | Paire de colonnes surface / prix |
|---|---|---|---|
| TVA sur marge | 20 % (normal) | `tva_marge` | `surface_tva_marge` / `prix_ttc_tva_marge` |
| TVA sur marge | 10 % (social) | `tva_marge_social` | `surface_tva_marge_social` / `prix_ttc_tva_marge_social` |
| TVA totale | 20 % (normal) | `tva_totale` | `surface_tva_totale` / `prix_ttc_tva_totale` |
| TVA totale | 10 % (social) | `tva_totale_social` | `surface_tva_totale_social` / `prix_ttc_tva_totale_social` |
| Sans TVA | sans objet (liste désactivée) | `agricole` | `surface_agricole` / `prix_ttc_agricole` |

`REGIMES_LOT`, `MODES_TVA_LOT`, `TAUX_TVA_LOT`, `regimeKeyPour()`, `onModeChange()`,
`onTauxChange()` : **aucune ligne modifiée** (test dédié sur les 5 définitions). Les taux
affichés restent ceux du scénario (`props.grille_prix.taux_tva.normal` / `.social`), jamais des
constantes.

## Ce qui reste dans le panneau « Détail »

- Case **Lot ODF** (`est_lot_odf`) + `onOdfToggle(row)`.
- **Surface ODF (m²)** (`surface_odf_m2`) et **Recettes ODF TTC** (`recettes_odf_ttc`), visibles
  seulement quand le lot est ODF.
- Case **Ventiler sur plusieurs régimes de TVA** (`multi_regimes`) et, si cochée, le tableau de
  ventilation sur les 5 régimes (`v-for="reg in REGIMES_LOT"`).
- L'avertissement « TVA sur marge négative ».
- En régime unique, une phrase qui **renvoie à la grille** : régime via Mode TVA / Taux,
  surface et prix via les colonnes.

RETIRÉS du panneau, car désormais dans la grille : N° commercial, Type, Tranche, flag Hectare,
et le select « Régime » avec ses champs surface / prix. Les y laisser aurait créé **deux points
de saisie pour la même donnée** — un piège classique de divergence. Le select « Régime » est
couvert sans perte par Mode × Taux : les 5 couples recouvrent exactement les 5 clés de
`REGIMES_LOT`.

## Garde-fous respectés

**Aucun fichier PHP applicatif modifié** — ni `FaisabiliteLotissementMoteur`, ni
`ScenarioGrillePrixService`, ni le contrôleur, ni aucune migration, ni aucune route. Le lot est
intégralement front. `calculerTvaLot()`, `calculerTotauxGrille()` et `calculerHonorairesLot()`
sont vérifiés présents et inchangés ; la page ne contient aucune arithmétique de TVA (`* 1.2`,
`/ 1.2`, `* 0.2`, affectation de `montant_tva_lot` : toutes absentes de la section).

Navigation clavier du #1635 conservée à l'identique : `registerCell(el, 'grille', idx, …)` sur
les 7 colonnes saisissables (lot, type, **tranche**, mode, taux, surface, prix),
`onCelluleEnter` sur chacune (y compris le saut de page), `memoriserValeur` + `annulerEdition`
pour Échap. Génération en masse inchangée : `GENERATION_MAX = 200`, `lotRows.value.push(ligne)`,
aucune réinitialisation de la collection. Scénario gelé : toute saisie sous
`v-if="peutEditerGrille"`, case H sous `:disabled="!peutEditerGrille"`, aucun champ focusable
donc aucun cadre de cellule active. Montants masqués sans `peut_prix`. Haut de page (#1641),
barre KPI et pastilles d'unité (#1640), étude financière, fiche foncière, assiette et
`lots_commerciaux` (#1632) non touchés. Aucun `overflow-x-hidden`.

# DÉPLOIEMENT-TEST

Sur OVH : `git pull origin main` puis `php artisan optimize:clear`, `view:clear`,
`journal:sync`. Ni migration, ni `route:clear`.

🔴 **AVANT de déployer** : ouvrir un scénario représentatif et NOTER le **Total TVA** affiché
(bande ou barre KPI). Après déploiement, rouvrir le même scénario : la valeur doit être
identique au centime. C'est le test n°1.

1. Total TVA du scénario témoin identique au centime avant / après.
2. 1366 px puis 1920 px : les 15 colonnes visibles, aucun ascenseur horizontal, aucune valeur
   tronquée dans la bande de KPI.
3. La grille a l'aspect d'une feuille de calcul : traits jointifs, lignes basses, champs sans
   bordure propre, nombres à droite en police tabulaire.
4. Cliquer une cellule : cadre bleu net, édition immédiate, **aucun décalage** de la grille.
5. Entrée descend d'une ligne, y compris en changeant de page ; Tabulation va à droite puis à la
   ligne suivante ; Échap rétablit la valeur d'avant la frappe.
6. Colonnes #, € / m², TVA, Hon. mont., Hon. gest. : grisées, non éditables, infobulle
   « Calculé (moteur) ».
7. Générer 20 lots : 20 lignes ajoutées et numérotées à la suite, l'existant intact.
   Saisir 500 : message de limite, rien n'est créé.
8. Modifier Tranche et la case H directement dans la grille, enregistrer, recharger :
   valeurs conservées.
9. « Détail » ouvre encore le panneau : case ODF, surface et recettes ODF, ventilation
   multi-régimes.
10. Ligne de totaux cohérente avec la bande de KPI (mêmes chiffres au centime). Vérifier les
    infobulles de périmètre sur Surface cessible et Prix TTC.
11. Scénario gelé : aucune saisie possible, aucun cadre de cellule, libellés lisibles
    (« Terrain à bâtir » et non `terrain_a_batir`).
12. Compte sans `foncier.prix` : les 5 colonnes de montants disparaissent, la grille reste
    alignée (10 colonnes).
13. Haut de page (#1641), barre KPI (#1640) et étude financière : strictement identiques.

# LEÇON

- L'effet « bulles » ne venait pas du tableau mais des COMPOSANTS de saisie : PrimeVue rend un
  conteneur bordé, arrondi et rembourré par défaut. Pour un rendu tableur il faut neutraliser
  `border`, `background`, `border-radius`, `padding` ET `box-shadow`/`ring` du champ, et laisser
  la bordure de cellule porter la grille. Pour un `Select`, `class` ne suffit pas : il faut
  passer par `:pt` (root + label + dropdown).
- Le cadre de cellule active doit être un `outline` avec offset NÉGATIF, jamais une `border` :
  une bordure ajoute 2 px au modèle de boîte et décale toute la ligne à chaque focus.
- `focus-within` sur la cellule évite tout état JS de « cellule sélectionnée » : c'est le
  navigateur qui suit le focus, et l'effet s'éteint tout seul en lecture seule puisqu'il n'y a
  plus de champ focusable.
- Un total de pied de tableau n'est pas forcément la somme de sa colonne : ici
  `surface_cessible_hors_agricole` exclut l'agricole et `total_recettes_lots_hectare_ttc` exclut
  l'ODF. Sourcer les totaux du moteur et NOMMER leur périmètre vaut mieux que fabriquer une
  somme d'affichage qui « colle » mais ne correspond à aucun calcul.
- Remonter un champ d'un panneau vers la grille exige de le RETIRER du panneau : deux points de
  saisie sur la même donnée finissent toujours par diverger dans la tête de l'utilisateur.
- Troisième fois sur ce chantier : un test source qui cherche une chaîne doit lire le fichier
  SANS ses commentaires. Le commentaire qui explique « N° commercial est retiré du panneau »
  contient « N° commercial » et fait échouer l'assertion de retrait.
- Un texte d'aide reformaté sur plusieurs lignes casse les assertions de lots antérieurs qui
  cherchent la phrase entière. Vérifier `--filter` sur TOUTES les suites de l'écran
  (`Faisabilite|Scenario|Grille`), pas seulement celle du lot.
