---
suivi: 1645
date: 2026-09-22
sujet: Diagnostic — rôle réel de l'assiette parcellaire (`scenario_parcelle`) dans un scénario de faisabilité
chantier: Foncier / Faisabilité
type: diagnostic
statut: poussé
hash: 25f72ede
fichiers:
  - docs/suivi/SUIVI_1645_diagnostic_role_assiette.md
---

# SUIVI #1645 — « L'assiette sert à quoi ? »

🔴 **Lecture seule.** Aucun fichier applicatif modifié, aucune migration, aucun test écrit,
aucune donnée touchée. Les deux requêtes SQL fournies sont en `SELECT` pur et seront exécutées
par Robin en production.

## PROMPT ENVOYÉ

Diagnostic du rôle de la table `scenario_parcelle` (assiette) sur `/foncier/scenarios/{id}` :
qui écrit, qui lit, effet sur les quatre services de calcul, comportement assiette vide,
nature de la divergence assiette ↔ fiche foncière constatée sur APS1
(assiette 3 575 m² vs fiche 4 225 m²), conclusion tranchée et proposition de refonte chiffrée.

## BASE GIT ET FICHES LUES

`HEAD` local = `origin/main` = `21c919d2` (chore hash fiche #1641). `git fetch` sans nouveauté,
aucun rebase nécessaire. Base MySQL locale inaccessible (constat récurrent #1626 → #1641) :
le schéma est relu sur le dump `hectareenvol.sql` + les migrations, pas par `DESCRIBE`.

Fiches consultées :

- **#1142** (socle scénarios, `statut: déployé`) — crée `scenarios` + `scenario_parcelle`.
  Sa synthèse dit explicitement « assiette, onglet Faisabilité — **aucun calcul** ». L'assiette
  est née AVANT le moteur (#1143) et avant la fiche foncière (#1144). Cela oriente la lecture :
  il ne faut pas chercher un branchement de calcul qui aurait été retiré, il n'y en a jamais eu.
- **#1641** (haut de page compact, `statut: poussé`) — a déjà densifié le bloc assiette et
  introduit `assietteTotalSurface`, décrit dans la fiche comme « **somme d'affichage** ».
  Fournit aussi la méthode de chiffrage de hauteur en pixels réemployée au § 5.
- **#1634** — table des cinq formulaires et de leurs endpoints ; confirme que
  « Enregistrer l'assiette » est doublé par la barre collante. Décisif pour la proposition.
- **#1636 / #1640** — saisie tableur et barre KPI ; confirment que l'assiette est restée hors
  du périmètre tableur (« identité, commentaire et assiette : saisie classique inchangée »).
- **#1286 / #1289** — `scenario_parcelle` fait partie du périmètre matérialisé d'un dossier et
  du recensement des engagements d'une parcelle. C'est le seul usage « métier » hors affichage.
- **#1213** — l'indicateur de maîtrise foncière (surface sécurisée ÷ assiette) est décrit comme
  **Phase 2**, donc non implémenté. Vérifié : aucun calcul de ce type n'existe dans `app/`.

Anti-collision : `docs/suivi/` ne contient que jusqu'à `SUIVI_1641`. Les lots #1642, #1643 et
#1644 annoncés en cours n'ont pas encore de fiche ; le numéro 1645 est libre.

---

## 1. QUI ÉCRIT DANS `scenario_parcelle` ?

**Un seul point d'écriture applicatif** dans tout le dépôt.

| # | Écriture | Fichier:ligne | Déclencheur |
|---|---|---|---|
| E1 | `$scenario->parcelles()->sync([])` — vidage total | `app/Http/Controllers/Foncier/ScenarioFaisabiliteController.php:689` | payload vide |
| E2 | `$scenario->parcelles()->sync($sync)` — création / mise à jour / suppression | `ScenarioFaisabiliteController.php:728` | payload non vide |

Les deux sont dans la même méthode privée `syncAssiette()` (`ScenarioFaisabiliteController.php:685-729`),
appelée depuis exactement deux endroits :

- `store()` — `ScenarioFaisabiliteController.php:88`, dans la transaction de création du scénario.
  Route `POST foncier.dossiers.scenarios.store`. Une assiette peut donc être posée dès la création.
- `updateAssiette()` — `ScenarioFaisabiliteController.php:158`.
  Route `PUT /foncier/dossiers/{dossierFoncier}/scenarios/{scenarioFaisabilite}/assiette`
  (`routes/web.php:1271`), c'est le bouton « Enregistrer l'assiette » de l'écran.

`sync()` fait les trois opérations d'un coup : les parcelles absentes du payload sont **supprimées**
de la table. Il n'existe aucun `attach()`, `detach()` ni `DB::table('scenario_parcelle')->insert/update/delete`
ailleurs dans `app/` (grep exhaustif `scenario_parcelle` sur `app/` → 3 fichiers seulement :
le modèle, et deux lectures citées au § 2).

**Suppression indirecte** : `scenario_id` porte `constrained('scenarios')->cascadeOnDelete()`
(`database/migrations/2026_07_31_230100_create_scenario_parcelle_table.php:16`). `destroy()`
(`ScenarioFaisabiliteController.php:342-353`) supprime le scénario ; MySQL supprime les lignes
d'assiette en cascade — pas de code PHP. `parcelle_id` porte `restrictOnDelete()` (migration L17),
mais `parcelles` est en SoftDeletes (`deleted_at` présent au dump) : le restrict ne se déclenche
jamais en pratique, la suppression de parcelle est un soft delete.

### `surface_retenue_m2` et `hypothetique` sont-ils bien persistés ?

**Oui, les deux.** Chaîne complète :

| Étape | Fichier:ligne |
|---|---|
| Validation HTTP | `ScenarioFaisabiliteController.php:643-644` (`nullable/numeric/min:0` et `sometimes/boolean`) |
| Extraction du payload | `ScenarioFaisabiliteController.php:671-676` (`''` → `null` ; `FILTER_VALIDATE_BOOLEAN`) |
| Tableau de pivot | `ScenarioFaisabiliteController.php:723-726` |
| Déclaration du pivot | `app/Models/ScenarioFaisabilite.php:215` — `->withPivot(['surface_retenue_m2','hypothetique'])` |
| Colonnes | migration L18-19 ; dump `hectareenvol.sql:4165-4173` |

Deux gardes métier **internes à l'assiette** dans `syncAssiette()` :

- `ScenarioFaisabiliteController.php:706-710` — une parcelle remplacée (filiation cadastrale #1132)
  est refusée par `ValidationException`.
- `ScenarioFaisabiliteController.php:711-718` — `surface_retenue_m2` supérieure à
  `surface_arpentee_m2 ?? surface_cadastrale_m2` de la parcelle est refusée.

Ces deux contrôles ne regardent que l'assiette elle-même. Aucun ne consulte la fiche foncière.

---

## 2. QUI LIT `scenario_parcelle` ?

Recherche exhaustive sur `app/` : nom de la table, nom du modèle, nom de la relation
`parcelles()` portée par `ScenarioFaisabilite`, méthode dérivée `surfaceAssietteM2()`,
et chacune des deux colonnes de pivot.

### Lectures back

| # | Lecture | Fichier:ligne | Nature |
|---|---|---|---|
| L1 | Relation `parcelles(): BelongsToMany` | `app/Models/ScenarioFaisabilite.php:212-217` | définition |
| L2 | `surfaceAssietteM2()` — Σ `surface_retenue_m2`, sinon surface totale de la parcelle | `app/Models/ScenarioFaisabilite.php:275-289` | **affichage seul** (voir L4) |
| L3 | `serializeScenario()` — `load('parcelles.commune')` puis map des lignes | `ScenarioFaisabiliteController.php:532-534` et `545-568` | **affichage** (payload Inertia) |
| L4 | `'surface_assiette_m2' => $s->surfaceAssietteM2()` | `ScenarioFaisabiliteController.php:592` | **affichage** — unique consommateur de L2 dans tout le dépôt |
| L5 | `showDirect()` — eager load `'parcelles.commune:code_insee,nom'` | `ScenarioFaisabiliteController.php:381` | **affichage** (évite N+1 de L3) |
| L6 | `snapshotAssiette()` — texte « AP 87 (9200.00 m²), BS 12 (totalité hyp.) » | `ScenarioFaisabiliteController.php:731-744` | **trace d'audit**, consommé en L161 (`recordChange($scenario,'assiette',$avant,$apres)`) |
| L7 | `serializeParcellesCandidates()` — parcelles du **dossier** proposées à l'ajout | `ScenarioFaisabiliteController.php:604-626` | **affichage** ; lit `dossier_foncier_parcelle`, pas `scenario_parcelle` |
| L8 | `DossierFoncierController::show()` — `'scenariosFaisabilite.parcelles.commune'` puis `serializeScenario` | `app/Http/Controllers/Foncier/DossierFoncierController.php:507` et `582` | **affichage** (onglet Faisabilité du dossier) |
| L9 | `SELECT` sur `scenario_parcelle as sp` JOIN `scenarios` — « cette parcelle figure dans le scénario X » | `app/Services/Foncier/DossierFoncierPerimetreService.php:301-315` | **avertissement** au retrait d'une parcelle du dossier (#1286) — message de confirmation, ne bloque pas (`messageConfirmationRetrait`, même fichier L338-348 : « avertir, pas refuser ») |
| L10 | `DB::table('scenario_parcelle')->where('parcelle_id',…)->count()` | `app/Services/Foncier/ParcelleSuppressionService.php:43`, restitué en L67-69 | **avertissement** avant suppression de parcelle |

Aucune de ces dix lectures n'alimente une grandeur calculée. L9 et L10 sont les seuls usages
non purement cosmétiques : ils protègent l'utilisateur contre un retrait/suppression de parcelle
silencieux. Ils consomment l'**existence** d'une ligne, jamais `surface_retenue_m2` ni `hypothetique`.

### Lectures front

| # | Lecture | Fichier:ligne | Nature |
|---|---|---|---|
| F1 | Hydratation de `assietteRows` depuis `props.scenario.parcelles` (+ `watch`) | `resources/js/Pages/Foncier/Scenarios/Show.vue:76-101` | affichage |
| F2 | `surfaceRetenueLigne()` / `assietteTotalSurface` — ligne TOTAL du tableau | `Show.vue:229-242` | **somme d'affichage côté client** (#1641) |
| F3 | KPI « Assiette » de la barre collante = `props.scenario.surface_assiette_m2` | `Show.vue:1368-1372` | affichage |
| F4 | Tableau de saisie (surface retenue, case hypothétique, TOTAL) | `Show.vue:1641-1696` | saisie |
| F5 | `saveAssiette()` → PUT `assiette_url` | `Show.vue:445-452` | écriture (→ E2) |
| F6 | Liste des scénarios du dossier : « N parcelle(s) · X m² », « · partiel », « · hypothétique » | `resources/js/Pages/Foncier/Dossiers/Show.vue:1144-1151` | affichage |

### Les quatre points à trancher nommément

#### `FaisabiliteLotissementMoteur` — **NON**, ni directement ni via une relation

Preuve la plus directe : `grep -E 'parcelle|scenario_parcelle|surface_retenue|hypothetique|surfaceAssiette'`
sur **tout** `app/Support/Foncier/Faisabilite/` → **zéro occurrence**.

Preuve structurelle : le moteur ne reçoit qu'un `FaisabiliteInput`
(`app/Support/Foncier/Faisabilite/FaisabiliteInput.php:19-26`), objet en mémoire à quatre champs —
`parametres`, `ficheLignes`, `lots`, `etudeFiSaisies`. Il n'y a **aucun canal** par lequel une
parcelle pourrait entrer : le moteur est documenté « sans effet de bord, aucune écriture DB,
entrée/sortie 100 % en mémoire » (`FaisabiliteLotissementMoteur.php:10-12`) et ne fait aucune
requête. `FicheLigneInput` (L14-23) porte `surfaceTotale`, `surfaceAchetee`, `surfaceOdf` — toutes
issues de `scenario_fiche_lignes`, aucune de `scenario_parcelle`.

⚠️ **Faux ami à ne pas confondre** : `FaisabiliteLotissementMoteur.php:217-218` contient
`$assietteImprevus = Dec::add($vrdLot1, …)`. C'est l'**assiette d'un taux** (la base sur laquelle
s'applique le taux d'imprévus travaux), homonyme sans aucun rapport avec l'assiette parcellaire.
Idem `FaisabilitePromotionMoteur.php:229-311` où « assiette » désigne la base de la taxe
d'aménagement (surfaces de plancher accession / social). Un grep sur le mot « assiette » seul
donne donc des faux positifs ; c'est le grep sur `parcelle` qui tranche.

**Grandeur qui en dépend : aucune.**

#### `ScenarioFicheFonciereService` — la fiche est saisie **totalement librement**

Ni préremplie, ni contrainte, ni contrôlée à partir de l'assiette.

- `syncLignes()` (`app/Services/Foncier/ScenarioFicheFonciereService.php:37-84`) : aucune référence
  à l'assiette, aucun chargement de `parcelles`.
- `normalizeRow()` (L89-135) : aucune borne de surface. `surface_totale_m2`, `surface_achetee_m2`,
  `surface_odf_m2` passent tels quels via `nullableDecimal()`.
- Le **seul** contrôle croisé de la fiche est `assertProprietairesDuDossier()` (L151-168, appelé en
  `ScenarioFaisabiliteController.php:199`) : il vérifie que le propriétaire appartient au **dossier**.
  Rien sur les parcelles. Et une ligne peut n'avoir **aucun** propriétaire, seulement un
  `libelle_libre` (L102-106) — donc aucun ancrage cadastral possible.
- `calculerAffichage()` (L183-256) : `loadMissing(['ficheLignes.proprietaire','lots','parametres'])`
  — `parcelles` n'est **pas** dans la liste, la relation n'est jamais chargée ici.
- Aucun préremplissage : il n'existe aucun code qui génère une ligne de fiche depuis une parcelle
  d'assiette. À la création d'un scénario, `store()` ne crée que le scénario, l'assiette et les
  paramètres (`ScenarioFaisabiliteController.php:87-91`) — zéro ligne de fiche.

Validation HTTP de la fiche : `ScenarioFaisabiliteController.php:183-196`. Aucune règle ne
référence l'assiette. **C'est exactement ce qui rend le cas APS1 possible** (§ 4).

#### `ScenarioGrillePrixService` — surface cessible et dénominateur viennent **uniquement** de la grille

- `surface_cessible_hors_agricole` est agrégée depuis les lots de `scenario_lots`
  (colonnes `surface_tva_marge`, `surface_tva_marge_social`, `surface_tva_totale`,
  `surface_tva_totale_social`, `surface_agricole` — `ScenarioGrillePrixService.php:26-37`),
  via `calculerTotauxGrille()` du moteur.
- Le **dénominateur du prix de revient foncier au m²** est cette même surface cessible hors
  agricole : `FaisabiliteLotissementMoteur.php:57-60` —
  `$surfaceHorsAgricole = $totauxGrille['surface_cessible_hors_agricole']` puis
  `Dec::div($basePrixRevientFoncier, $surfaceHorsAgricole)`.
- Le **numérateur** (`base_prix_revient_foncier`, moteur L50-54) = Σ prix TVA sur marge
  + Σ prix TVA totale (fiche) + frais de notaire. Fiche + grille, jamais assiette.
- `buildInput()` (L222-249) : `loadMissing(['ficheLignes','lots','parametres'])` — pas `parcelles`.
- La convention est d'ailleurs nommée en dur : `CONVENTION_PRIX_REVIENT_M2 = 'prorata surface
  cessible hors agricole'` (L23). Pas « prorata assiette ».

#### `ScenarioEtudeFiService` — **aucune ligne** de l'étude Fi n'en dépend

`calculerAffichage()` (`app/Services/Foncier/ScenarioEtudeFiService.php:95-147`) :
`loadMissing(['ficheLignes','lots','parametres','etudeFiPostes'])` (L97), puis `FaisabiliteInput`
construit L108-113 avec ces seules sources. `syncSaisies()` (L33-90) n'écrit que
`scenario_etude_fi_postes` sur une liste blanche de codes (`EtudeFiSaisies::codes()`).
Toutes les lignes de l'étude Fi descendent de `calculerEtudeFi()` du moteur, qui reçoit
totaux grille, coût d'achat foncier HT, coût viabilité ODF TTC, frais de notaire, honoraires
et TVA (moteur L108-119) — aucune surface d'assiette.

#### Le drapeau `hypothetique` — **aucun effet hors de l'écran**

Occurrences exhaustives dans tout le dépôt (hors tests) :

| Fichier:ligne | Rôle |
|---|---|
| migration L19 | colonne `tinyint(1) DEFAULT 0` |
| `ScenarioFaisabilite.php:215` | déclaré dans `withPivot` |
| `ScenarioFaisabiliteController.php:644` | règle de validation |
| `ScenarioFaisabiliteController.php:675` | extraction du payload |
| `ScenarioFaisabiliteController.php:725` | écriture du pivot |
| `ScenarioFaisabiliteController.php:564` | sérialisation vers le front |
| `ScenarioFaisabiliteController.php:738` | texte d'audit (` hyp.`) |
| `Scenarios/Show.vue:84, 97, 122, 449, 1673` | hydratation, valeur par défaut, envoi, case à cocher |
| `Dossiers/Show.vue:1150` | mention « · hypothétique » |

**Preuve de l'absence d'effet** : la boucle est fermée — écrit, relu, réaffiché, jamais évalué.
Le drapeau n'apparaît dans **aucun** `where`, **aucun** filtre, **aucune** somme. En particulier
`surfaceAssietteM2()` (`ScenarioFaisabilite.php:275-289`) additionne **toutes** les parcelles sans
jamais tester `hypothetique` : une parcelle hypothétique pèse exactement autant qu'une parcelle
maîtrisée dans le total affiché. Et zéro occurrence dans `app/Support/` comme dans les quatre
services de calcul.

⚠️ **Constaté, non corrigé** (garde-fou : ce lot ne code pas) : le texte d'aide de l'écran
(`Scenarios/Show.vue:1592-1594`) affirme qu'une parcelle hypothétique est « comptée dans l'étude
sans être maîtrisée ». C'est inexact au sens strict — elle n'est comptée dans **aucune** étude,
seulement dans un total d'affichage. À arbitrer si la refonte retient l'option (c) du § 5.

---

## 3. QUE SE PASSE-T-IL SI L'ASSIETTE EST VIDE ?

**Par lecture du code : un scénario sans aucune ligne de `scenario_parcelle` se calcule
normalement et intégralement. Aucun contrôle ne bloque nulle part.**

| Preuve | Fichier:ligne |
|---|---|
| L'assiette est facultative à la création — `'parcelles' => ['nullable','array']` | `ScenarioFaisabiliteController.php:641` |
| Vider l'assiette est un chemin nominal, pas une erreur — `if ($ids === []) { sync([]); return; }` | `ScenarioFaisabiliteController.php:688-692` |
| `updateFiche()` — aucune garde sur l'assiette | `ScenarioFaisabiliteController.php:173-206` |
| `updateGrille()` — aucune garde sur l'assiette | `ScenarioFaisabiliteController.php:211-250` |
| `updateEtudeFi()` — aucune garde sur l'assiette | `ScenarioFaisabiliteController.php:255-276` |
| Le verrouillage du mode dépend de la fiche et des lots, **pas** de l'assiette — `aDesSaisiesCalcul()` ne teste que `scenario_fiche_lignes` et `scenario_lots` | `app/Models/ScenarioFaisabilite.php:249-270` |
| Les trois `calculerAffichage()` renvoient un payload vide selon les lignes de fiche / lots, jamais selon les parcelles | `ScenarioFicheFonciereService.php:195-197` ; `ScenarioGrillePrixService.php:175-177` |
| `snapshotAssiette()` prévoit explicitement le cas vide — retourne `'(vide)'` | `ScenarioFaisabiliteController.php:743` |
| L'écran prévoit la ligne « Aucune parcelle dans l'assiette. » sans aucun avertissement | `Scenarios/Show.vue:1680-1684` |

Il n'existe donc ni validation, ni garde, ni message qui exige une assiette. Le seul message
lié à l'assiette est le **résumé** `assietteResume` (`Show.vue:244-248`), purement informatif.

### MESURE DE PRODUCTION — requête 1 (LECTURE SEULE, à exécuter par Robin)

Compte et liste les scénarios ayant **au moins une ligne de fiche foncière** et **aucune ligne
d'assiette**. Si ce cas existe, c'est la preuve directe, par la donnée, que l'assiette n'est pas
nécessaire au calcul.

```sql
-- SUIVI #1645 — requête 1 : scénarios calculés SANS assiette (lecture seule)
SELECT
    s.id                                   AS scenario_id,
    df.reference                           AS dossier,
    CONCAT('S', s.version, ' — ', s.libelle) AS scenario,
    s.mode,
    s.statut,
    COUNT(DISTINCT fl.id)                  AS nb_lignes_fiche,
    COUNT(DISTINCT sl.id)                  AS nb_lots_grille,
    COUNT(DISTINCT ep.id)                  AS nb_postes_etude_fi
FROM scenarios s
JOIN dossiers_fonciers df          ON df.id = s.dossier_foncier_id
LEFT JOIN scenario_fiche_lignes fl ON fl.scenario_id = s.id
LEFT JOIN scenario_lots sl         ON sl.scenario_id = s.id
LEFT JOIN scenario_etude_fi_postes ep ON ep.scenario_id = s.id
WHERE NOT EXISTS (
    SELECT 1 FROM scenario_parcelle sp WHERE sp.scenario_id = s.id
)
GROUP BY s.id, df.reference, s.version, s.libelle, s.mode, s.statut
HAVING nb_lignes_fiche > 0
ORDER BY df.reference, s.version;
```

Lecture du résultat : **au moins une ligne ⇒ l'assiette n'est pas nécessaire au calcul, preuve
par la production.** Zéro ligne ⇒ tous les scénarios saisis ont une assiette, mais cela ne
prouverait pas qu'elle soit *utilisée* : le § 2 montre par le code qu'elle ne l'est pas. Ce
serait alors un simple usage constant, pas une dépendance.

---

## 4. LA DIVERGENCE EST-ELLE NORMALE OU ANORMALE ?

### Ce que mesure chacune des deux surfaces

| | Assiette (`scenario_parcelle`) | Fiche foncière (`scenario_fiche_lignes`) |
|---|---|---|
| Unité de la ligne | une **parcelle cadastrale** | un **propriétaire** (ou un `libelle_libre` sans propriétaire) |
| Grandeur | `surface_retenue_m2` — surface **retenue** pour le projet, `NULL` = totalité de la parcelle | `surface_achetee_m2` — surface **achetée** au propriétaire |
| Périmètre | peut inclure des parcelles **hors dossier** (`searchParcelles`, `ScenarioFaisabiliteController.php:472-473`) | propriétaires **du dossier** uniquement (`assertProprietairesDuDossier`) |
| Peut être non maîtrisée | oui (`hypothetique`) | sans objet |

Les deux tables n'ont **aucune clé commune** : il n'existe pas de `parcelle_id` sur
`scenario_fiche_lignes` (dump `hectareenvol.sql:4079-4095`). Un rapprochement ligne à ligne est
donc **structurellement impossible** en l'état, et pas seulement absent.

### Le métier autorise-t-il légitimement qu'elles diffèrent ? Oui.

Cas réels où la divergence est normale, tous permis par le modèle :

1. **Emprise achetée plus large que l'assiette du projet** — un propriétaire refuse de couper sa
   parcelle, on achète 4 225 m² pour n'en aménager que 3 575. L'assiette porte la surface
   *retenue*, la fiche la surface *payée*. C'est précisément l'usage de `surface_retenue_m2`
   nullable + le garde-fou « retenue ≤ surface parcelle » (L711-718).
2. **Parcelle achetée en partie** — même parcelle, surface retenue < surface cadastrale.
   Les deux tables divergent par construction.
3. **Indivision / propriétaires multiples sur une même parcelle** — une parcelle d'assiette,
   plusieurs lignes de fiche. Les surfaces de fiche se cumulent face à une seule ligne d'assiette.
4. **Ligne de fiche sans ancrage cadastral** — `libelle_libre` sans propriétaire
   (`ScenarioFicheFonciereService.php:102-106`) : une ligne de fiche peut ne correspondre à aucune
   parcelle identifiée.
5. **Parcelle hypothétique** — inscrite en assiette sans être maîtrisée, donc sans ligne de fiche.
   Divergence dans l'autre sens.

### Le cas APS1 précisément

La ligne de 650 m² est en régime **« Sans TVA »**. Ce régime est **exclu de la base du prix de
revient foncier** : `base_prix_revient_foncier` = Σ TVA sur marge + Σ TVA totale + frais de notaire
(`FaisabiliteLotissementMoteur.php:50-54`), rappel utilisateur affiché en
`ScenarioFicheFonciereService.php:254`. Une surface achetée « sans TVA » est donc une surface
**payée mais délibérément hors base de prix de revient** — un cas explicitement modélisé, pas un
accident de saisie.

**NON ÉTABLI par le code** : savoir si ces 650 m² correspondent à une parcelle réellement absente
de l'assiette (divergence légitime, cas 1 ou 4) ou à une parcelle oubliée à la saisie (incohérence).
Le code ne permet pas de trancher, puisqu'il n'y a aucun lien parcelle ↔ ligne de fiche.
Ce qu'il faudrait mesurer pour trancher : la requête 2 ci-dessous, puis un contrôle métier
au cas par cas sur les scénarios qui en ressortent — arbitrage humain, pas automatisable en l'état.

### Existe-t-il un contrôle de cohérence aujourd'hui ?

**Non, aucun.** Ni validation, ni signal, ni message.

- Validation de la fiche (`ScenarioFaisabiliteController.php:183-196`) : aucune règle croisée.
- `syncLignes()` / `normalizeRow()` : aucune comparaison.
- Les `signaux` du moteur ne portent que sur la TVA sur marge négative
  (`FaisabiliteLotissementMoteur.php:73-79`) — seul signal métier existant du chantier.

### Existe-t-il un contrôle comparable ailleurs dans l'ERP ?

- **Couple de surfaces le plus proche** : `offre_parcelle.surface_souhaitee_m2`
  (`OffreFonciereController.php:874`) — même mécanique de pivot avec surface partielle,
  et **là non plus aucun contrôle croisé** avec une autre table de surfaces.
- **Seul contrôle de surface existant dans tout le chantier foncier** :
  `ScenarioFaisabiliteController.php:711-718`, retenue ≤ surface de la parcelle. Il compare une
  surface à **sa propre parcelle**, pas deux tables entre elles.
- **Alerte de cohérence la plus proche en esprit** : `PromesseParcelleCoherenceType`
  (`app/Support/Foncier/Alertes/Types/PromesseParcelleCoherenceType.php:55-90`) — compare le
  **statut** d'une promesse au **statut** de ses parcelles (promesse signée ⇒ parcelle attendue
  « sous compromis », et réciproquement). Précédent utile : l'ERP sait déjà émettre une **alerte**
  de cohérence non bloquante sur un couple promesse ↔ parcelle. Mais elle porte sur des statuts,
  pas sur des surfaces.

**Conclusion du § 4** : la divergence est **normale et permise par le métier**, et le modèle de
données la rend même attendue. En revanche il n'existe **aucun garde-fou** pour distinguer une
divergence légitime d'un oubli de saisie. C'est un angle mort, pas un bug.

### MESURE DE PRODUCTION — requête 2 (LECTURE SEULE, à exécuter par Robin)

Total assiette (en reproduisant la règle de `surfaceAssietteM2()` : surface retenue, sinon
arpentée, sinon cadastrale), total des surfaces achetées de la fiche, et écart, pour chaque
scénario. Les deux totaux sont calculés en sous-requêtes séparées : un `JOIN` direct des deux
tables multiplierait les lignes et fausserait les sommes.

```sql
-- SUIVI #1645 — requête 2 : écart assiette ↔ fiche foncière, par scénario (lecture seule)
SELECT
    s.id                                     AS scenario_id,
    df.reference                             AS dossier,
    CONCAT('S', s.version, ' — ', s.libelle) AS scenario,
    s.statut,
    COALESCE(a.nb_parcelles, 0)              AS nb_parcelles,
    COALESCE(a.total_assiette_m2, 0)         AS total_assiette_m2,
    COALESCE(f.nb_lignes, 0)                 AS nb_lignes_fiche,
    COALESCE(f.total_achete_m2, 0)           AS total_achete_m2,
    COALESCE(f.total_achete_m2, 0) - COALESCE(a.total_assiette_m2, 0) AS ecart_m2
FROM scenarios s
JOIN dossiers_fonciers df ON df.id = s.dossier_foncier_id
LEFT JOIN (
    SELECT
        sp.scenario_id,
        COUNT(*) AS nb_parcelles,
        SUM(COALESCE(sp.surface_retenue_m2, p.surface_arpentee_m2, p.surface_cadastrale_m2, 0)) AS total_assiette_m2
    FROM scenario_parcelle sp
    JOIN parcelles p ON p.id = sp.parcelle_id
    GROUP BY sp.scenario_id
) a ON a.scenario_id = s.id
LEFT JOIN (
    SELECT
        fl.scenario_id,
        COUNT(*) AS nb_lignes,
        SUM(COALESCE(fl.surface_achetee_m2, 0)) AS total_achete_m2
    FROM scenario_fiche_lignes fl
    GROUP BY fl.scenario_id
) f ON f.scenario_id = s.id
ORDER BY ABS(COALESCE(f.total_achete_m2, 0) - COALESCE(a.total_assiette_m2, 0)) DESC,
         df.reference, s.version;
```

Lecture du résultat : `ecart_m2 = 0` sur la grande majorité ⇒ les utilisateurs saisissent de fait
deux fois la même surface, et l'assiette est une redite coûteuse. Écarts fréquents et variés ⇒ les
deux surfaces mesurent réellement deux choses différentes, et l'assiette porte une information
propre qu'il faut conserver (même si elle n'entre dans aucun calcul). Dans les deux cas la
conclusion du § 5 ne change pas ; c'est la **proposition** de refonte qui en dépend.

---

## 5. CONCLUSION TRANCHÉE

> **(c) — L'assiette est purement déclarative et documentaire.**
> Aucune grandeur calculée du scénario — ni fiche foncière, ni grille de prix, ni étude Fi, ni
> aucun KPI de la barre collante — ne dépend de `scenario_parcelle` : le moteur
> `FaisabiliteLotissementMoteur` ne reçoit que paramètres, lignes de fiche, lots et postes d'étude,
> et ne contient aucune occurrence du mot `parcelle`.

Ce n'est **pas** (b) : il n'y a pas de dépendance partielle à préciser. La seule sortie chiffrée
de l'assiette, `surface_assiette_m2` (`ScenarioFaisabiliteController.php:592`), n'est consommée
que par deux affichages — le KPI « Assiette » (`Scenarios/Show.vue:1368-1372`) et la colonne de la
liste des scénarios du dossier (`Dossiers/Show.vue:1144`).

**Déclarative ne veut pas dire inutile.** L'assiette rend trois services réels, qu'une refonte ne
doit pas détruire :

1. **Traçabilité** — audit horodaté des changements d'assiette (`recordChange(…, 'assiette', …)`,
   `ScenarioFaisabiliteController.php:161`), qui documente quelles parcelles ont été envisagées.
2. **Garde-fou au retrait / à la suppression d'une parcelle** — `DossierFoncierPerimetreService.php:301-315`
   et `ParcelleSuppressionService.php:43` avertissent l'utilisateur qu'une parcelle est engagée
   dans un scénario. Sans l'assiette, ces avertissements disparaissent.
3. **Périmètre documentaire du dossier** (#1289) — rattachement parcelle ↔ scénario.

### PROPOSITION soumise à Robin — à arbitrer, rien n'est implémenté

L'assiette n'étant pas une entrée de calcul, elle n'a pas besoin d'occuper le même rang visuel
que la fiche, la grille et l'étude Fi, qui sont les trois vraies sections de saisie.

**Option proposée : replier l'assiette en une ligne de l'en-tête**, sur le modèle déjà appliqué
par #1641 au commentaire (dépliant derrière un bouton bulle) et aux documents (bouton compteur).
Forme : `▸ Assiette · 2 parcelles · 3 575 m²`, le tableau complet s'ouvrant au clic, avec la
saisie strictement inchangée une fois déplié.

**Chiffrage de la hauteur libérée** (méthode #1641 : somme des classes Tailwind lues dans
`Scenarios/Show.vue:1576-1708`, cas APS1 à 2 parcelles) :

| Poste du bloc assiette actuel | Source | Hauteur |
|---|---|---|
| Bordure haute de section | L1578 | 1 px |
| En-tête (titre + résumé + « ? » + recherche 230 px) : `py-1.5` 12 + `InputText !h-7` 28 + bordure 1 | L1583-1620 | 41 px |
| Rangée « parcelles du dossier non retenues » : `py-1` 8 + pastille 18 + bordure 1 *(conditionnelle)* | L1627-1639 | 27 px |
| `thead` : `py-1` 8 + texte `10px` 14 | L1643-1651 | 22 px |
| 2 lignes de données `h-7` | L1654 | 56 px |
| `tfoot` TOTAL `h-7` | L1687-1694 | 28 px |
| Pied « Enregistrer l'assiette » : bordure 1 + `py-1.5` 12 + `Button !h-7` 28 | L1699-1707 | 41 px |
| Bordure basse de section | — | 1 px |
| **Total actuel (2 parcelles)** | | **217 px** (190 px sans la rangée de candidats) |
| **Version repliée** — une ligne de 28 px | | **28 px** |
| **Gain** | | **189 px** |

Le gain croît de 28 px par parcelle supplémentaire : **273 px** à 5 parcelles (bloc de 301 px),
**413 px** à 10 parcelles. Et **0 px ajouté** si la pastille est glissée dans la ligne du titre
existante plutôt que sur une ligne propre — le gain monte alors à 217 px dès 2 parcelles.

**Sur le bouton « Enregistrer l'assiette » : il est déjà redondant aujourd'hui.** La barre KPI
collante du #1634 sait enregistrer l'assiette — `SECTIONS_ENREGISTRABLES.assiette` avec
`save: () => saveAssiette()` (`Scenarios/Show.vue:1245`), bouton contextuel qui se libelle
« Enregistrer l'assiette » dès qu'une saisie touche la section (`onSectionInput('assiette')`,
L1580-1581). Supprimer le bouton du pied (L1699-1707) rendrait donc **41 px** sans perdre aucune
fonction. C'est un arbitrage **indépendant** du repli : il peut être pris seul.

**Trois options, à trancher par Robin :**

- **A — repli complet** : ligne repliée + suppression du bouton de pied. Gain 189 à 217 px à
  2 parcelles, davantage ensuite. L'assiette devient ce qu'elle est : une donnée de contexte.
- **B — repli seul** : ligne repliée, bouton de pied conservé dans le dépliant. Gain identique à
  l'état replié, aucun changement d'habitude une fois déplié.
- **C — statu quo** : garder le bloc de plein rang. Défendable si la requête 2 montre des écarts
  fréquents et significatifs, c'est-à-dire si l'assiette porte une information métier propre que
  les utilisateurs consultent réellement à chaque ouverture du scénario.

**Recommandation** : attendre la requête 2 avant d'arbitrer entre A/B et C. Si les écarts sont
quasi toujours nuls, l'assiette est une double saisie et A se justifie pleinement ; si les écarts
sont fréquents, elle porte une information propre et mérite au moins d'être visible repliée avec
son total (option B), voire signalée. Dans tous les cas, la suppression du bouton de pied (41 px)
est acquise sans contrepartie, puisque la barre collante le double déjà.

⚠️ **Hors périmètre de ce lot, noté et non corrigé** : le texte d'aide
`Scenarios/Show.vue:1592-1594` (« comptée dans l'étude ») est trompeur puisque l'assiette n'entre
dans aucune étude. À reprendre si l'option A ou B est retenue.

---

## DÉPLOIEMENT / TEST

Aucun déploiement. Aucun fichier applicatif, aucune migration, aucune entrée de journal
(un diagnostic n'est pas une mise à jour visible par les utilisateurs).

Les deux requêtes des § 3 et § 4 sont en `SELECT` pur et sont à exécuter **par Robin** en
production. Elles ne modifient aucune donnée.

## LEÇON

1. **Une table écrite et relue n'est pas une table utilisée.** `scenario_parcelle` a une écriture
   soignée (validation, bornes, audit, cascade) et dix lectures — toutes d'affichage ou
   d'avertissement. La preuve d'une dépendance de calcul ne se lit pas dans le volume de code
   autour d'une table, mais dans ce que reçoit le moteur. Ici `FaisabiliteInput` à quatre champs
   (`FaisabiliteInput.php:19-26`) ferme la question en une lecture : aucun canal parcelle.
2. **Le mot « assiette » est ambigu dans ce chantier.** Il désigne l'assiette *parcellaire*
   (`scenario_parcelle`) mais aussi l'assiette d'un *taux* dans les deux moteurs
   (`FaisabiliteLotissementMoteur.php:217`, `FaisabilitePromotionMoteur.php:229-311`). Un grep sur
   « assiette » donne des faux positifs qui font croire à un branchement de calcul. Grep `parcelle`.
3. **L'ordre des lots explique l'architecture.** L'assiette est née au #1142, avant le moteur
   (#1143) et la fiche (#1144), avec la mention « aucun calcul ». Lire la fiche du lot fondateur
   évite de chercher un branchement qui n'a jamais existé.
4. **Deux surfaces qui mesurent deux choses différentes ne se contrôlent pas automatiquement.**
   `scenario_fiche_lignes` n'a pas de `parcelle_id` : le rapprochement est structurellement
   impossible, pas seulement absent. Avant de proposer un contrôle de cohérence, vérifier qu'une
   clé de rapprochement existe.
