---
suivi: 1651
date: 2026-09-22
sujet: Endpoint JSON de recalcul à blanc (étape β, diagnostic #1650)
chantier: Foncier / Faisabilité
type: feature
statut: poussé
hash: 45151664
fichiers:
  - app/Services/Foncier/ScenarioRecalculABlancService.php
  - app/Http/Controllers/Foncier/ScenarioFaisabiliteController.php
  - routes/web.php
  - tests/Unit/ScenarioRecalculABlanc1651SourceTest.php
  - tests/Feature/Foncier/FoncierScenarioRecalculABlanc1651Test.php
  - database/data/journal_mises_a_jour_post_2026_06_12.php
  - docs/suivi/SUIVI_1651_endpoint_recalcul_a_blanc.md
---

# SUIVI #1651 — endpoint recalcul à blanc

## PROMPT ENVOYÉ

Étape β du découpage #1650. Back pur : une méthode de calcul à blanc +
POST JSON `/foncier/dossiers/{dossierFoncier}/scenarios/{scenarioFaisabilite}/recalcul`.
Aucune persistance. Un seul run moteur. Liste blanche `seq` / `lignes` / `lots` /
`postes` (pas de `parcelles`). Droits `view`+`update`, strip `foncier.prix`,
scénario gelé 403. Aucun `.vue`, aucun rebuild Vite, aucune migration.

## FICHES LUES

- **#1650** (`cdeee33a`, poussé) — diagnostic repris tel quel : moteur sans I/O,
  `fromArrays` déjà là, les trois `calculerAffichage()` ne peuvent pas recevoir
  un brouillon, `showDirect` lance le moteur trois fois, assiette hors graphe
  (#1645), 25 comptes GS_HECTARE, ~25 Ko, limite 200 lots (#1635). NE PAS
  réutiliser les chemins d’écriture. JSON comme `previewEtudeFiImport`.
- **#1645** — assiette hors calcul ; `parcelles` interdit dans le payload.
- **#1648 / #1649** — PUT d’enregistrement intacts ; `Show.vue` hors de ce lot
  (série zones communes, lot γ ultérieur).
- **#1143 / #1139 / #1144 / #1145** — moteur, étude Fi, fiche, grille : lecture
  des `calculerAffichage` pour recopier la **forme** de réponse, pas la logique.

Anti-collision : dernier numéro committé 1650. **1651 est libre.**

## SYNTHÈSE

### Méthode

`ScenarioRecalculABlancService::calculerABlanc($scenario, $lignes, $lots, $postes, $peutPrix)`

1. Seule lecture : `loadMissing('parametres')`.
2. Mapping clés écran (`surface_*_m2`) → clés moteur (`surface_*`).
3. `FaisabiliteInput::fromArrays` puis **un** `$this->moteur->calculer($input)`.
4. Dérive les trois blocs `fiche_fonciere` / `grille_prix` / `etude_financiere`
   depuis ce seul `toArray()`.
5. Strip montants si `!$peutPrix` (clés absentes, pas masquées).
6. `InvalidArgumentException` → `ValidationException` 422.

Ne passe par aucun `calculerAffichage`, `updateFiche`, `updateGrille`,
`updateEtudeFi`, `syncLignes`, `syncLots`, `syncSaisies`.

### Preuve un seul run moteur

- Service : une seule occurrence de `$this->moteur->calculer(` (L100).
- `showDirect` : trois `calculerAffichage` (L472-474) — un par service, chacun
  lance le moteur. Le recalcul à blanc les contourne.
- Test `un_seul_appel_moteur_dans_calculer_a_blanc` : `preg_match_all` = 1.

### Route

`POST /foncier/dossiers/{dossierFoncier}/scenarios/{scenarioFaisabilite}/recalcul`
nom `foncier.dossiers.scenarios.recalcul`, middleware `foncier.gestion`,
`JsonResponse` (pas Inertia). Patron `previewEtudeFiImport`.

PUT fiche / grille / étude Fi **intouchés**.

### Contrat JSON

**Entrée** (liste blanche stricte, le reste est ignoré par `validate`) :

- `seq` entier nullable
- `lignes[]` max 200 — mêmes champs que PUT fiche, **sans** `exists:proprietaires`
  (brouillon, pas d’écriture)
- `lots[]` max 200 — mêmes champs que PUT grille
- `postes.{26 codes}` — mêmes que PUT étude Fi
- **pas** de `parcelles`

**Sortie** :

- `seq` renvoyé tel quel (null si absent)
- `peut_prix`
- `fiche_fonciere` / `grille_prix` / `etude_financiere` — mêmes clés racine que
  les props Inertia de `showDirect` (voir tableau champ par champ ci-dessous)

Les KPI de la barre (`kpisPrincipaux` / `kpisContexte`) ne sont pas un 4e bloc :
ils se calculent dans `Show.vue` à partir de `etude_financiere.totaux`,
`fiche_fonciere.base_prix_revient_foncier` / `prix_revient_foncier_m2` et
`grille_prix.totaux`. Le front γ réutilisera ces mêmes clés.

### Validation / 422

- Valeur non numérique, régime TVA hors liste, > 200 lots/lignes → **422**,
  jamais 500.
- `InvalidArgumentException` du moteur (Brick) → 422
  `payload: Valeur numérique invalide pour le moteur de faisabilité.`
- Payload ~25 Ko vs `post_max_size` web **520M** (`fpm-fcgi`, #1229) : aucune
  limite serveur ne gêne.

### Droits

- `authorize('view')` + `authorize('update')` + `assertNonGele` (403).
- Sans `foncier.prix` : 200, montants **absents** du JSON.
- `Gate::before` true pour GS_ADMIN_ERP : le compte Robin ne prouve aucun 403.

**Profil tiers pour les 403** (hors GS_ADMIN_ERP, hors direction) :

1. Dossier 403 — compte **GS_HECTARE** seul (lecture+gestion+prix), **pas**
   créateur du dossier, **aucune** ligne `record_habilitations` gestion sur
   ce dossier. Ex. un commercial Hectare qui n’est pas sur DF-2026-0013.
2. Gelé 403 — le même compte **habilité** sur le dossier, scénario `gele = 1`.
3. Sans prix — aucun groupe métier Hectare n’a `foncier.gestion` sans
   `foncier.prix` (GS_HECTARE a les trois). Preuve : test Feature
   `sans_foncier_prix_aucun_montant` (groupe `GS_RECAL_L` lecture+gestion).
   Recette prod : compte test hors GS_ADMIN_ERP avec `foncier.gestion` +
   habilitation dossier, **sans** `foncier.prix`.

### Non-persistance

Test source : aucun `save` / `update` / `create` / `delete` / `sync` /
`DB::transaction` / audit / Event / Log / Cache dans le service. Une seule
lecture `loadMissing('parametres')`. Contrôleur `recalcul` : aucun appel aux
trois PUT.

Test Feature `empreintePersistance` (counts + `updated_at`) avant/après.

Requête de contrôle (coller en prod, remplacer `:id`) :

```
SELECT
  (SELECT COUNT(*) FROM scenario_fiche_lignes WHERE scenario_id = :id) AS nb_lignes,
  (SELECT COUNT(*) FROM scenario_lots WHERE scenario_id = :id) AS nb_lots,
  (SELECT COUNT(*) FROM scenario_etude_fi_postes WHERE scenario_id = :id) AS nb_postes,
  (SELECT updated_at FROM scenario_faisabilites WHERE id = :id) AS scenario_at,
  (SELECT MAX(updated_at) FROM scenario_fiche_lignes WHERE scenario_id = :id) AS fiche_at,
  (SELECT MAX(updated_at) FROM scenario_lots WHERE scenario_id = :id) AS lots_at,
  (SELECT MAX(updated_at) FROM scenario_etude_fi_postes WHERE scenario_id = :id) AS postes_at;
```

Exécuter **avant** et **après** le POST : les sept valeurs doivent être identiques.

### Parité des totaux

Test `donnees_enregistrees_identiques_a_laffichage` : blanc vs les trois
`calculerAffichage` sur fixture persistée (même `totaux` fiche / grille /
étude + BASE PR + frais notaire + PR/m²). Feature skipped ici (`pdo_sqlite`
absent) — à jouer en prod / MySQL.

Référence Robin APS1 (DF-2026-0013) : étude Fi 1 599 500,00 / 1 069 280,00 /
333 636,67 / 462 606,67 ; BASE PR FONCIER 469 550,00 €. Si le POST avec les
données **enregistrées** diverge, le blanc est faux — ne pas « ajuster ».

Preuve locale sans BDD : fixture moteur vs `calculerABlanc` — totaux fiche,
BASE PR, frais notaire, totaux grille, totaux étude Fi identiques
(`un_run_moteur_alimente_les_trois_blocs_identiques_a_la_fixture`).

### Temps 200 lots

`calculerABlanc` 200 lots (hors HTTP) : **31,54 ms** (run source test).
Seuil test : < 500 ms.

### Closures

`recalcul()` : aucune closure. Service : foreach uniquement, zéro `use()`.
Les `use()` existants du contrôleur (`store` / `update` / `updateAssiette` /
`searchParcelles`) n’ont pas été touchés. `php -l` / Reflection ne détectent
pas une variable non capturée dans un `use()` — relues une par une : N/A
sur le code ajouté.

### Front

Aucun `.vue`, aucun `public/build/`. Confirmé `git show --name-only` filtré.

## Forme exacte vs page (champ par champ)

Racine JSON vs props `showDirect` :

| Clé JSON | Prop page | Identique ? |
|---|---|---|
| `seq` | — | nouveau (debounce γ) |
| `peut_prix` | `peut_prix` | oui |
| `fiche_fonciere` | `fiche_fonciere` | clés racine identiques |
| `grille_prix` | `grille_prix` | clés racine identiques |
| `etude_financiere` | `etude_financiere` | clés racine identiques |

`fiche_fonciere` : `lignes`, `totaux`, `base_prix_revient_foncier`,
`frais_notaire`, `prix_revient_foncier_m2`, `convention_prix_revient_m2`,
`rappel_sans_tva`.

Ligne : `id`, `ordre`, `proprietaire_id`, `proprietaire_label`, `libelle_libre`,
`libelle_affiche`, `assujetti_tva`, `surface_totale_m2`, `surface_achetee_m2`,
`surface_odf_m2`, `nb_lots_odf`, `regime_tva`, `regime_tva_libelle`,
`mode_prix_affiche`, `surface_tva_sur_marge`, `surface_tva_totale`,
`surface_sans_tva` + si prix : `prix_achat_numeraire`, `prix_achat_odf_ttc`,
`prix_achat_total`, `prix_m2_surface_achetee`, `prix_m2_global`,
`prix_tva_sur_marge`, `prix_tva_totale`, `prix_sans_tva`.

`grille_prix` : `lots`, `totaux`, `intermediaires`, `convention_prix_revient_m2`,
`signaux`, `taux_tva.{normal,social}`.

Lot : `id`, `numero`, `est_lot_hectare`, `est_lot_odf`, `surface_odf_m2`,
`tranche`, `numero_lot_commercial`, `type_lot`, `type_lot_libelle`,
`libelle_affiche`, surfaces TVA/agricole, `surface_cessible`,
`tva_marge_negative` + si prix : recettes/prix TTC, `prix_lot_ttc`, `prix_m2`,
TVA, honoraires.

`etude_financiere` si prix : `sections`, `totaux`, `saisies`, `codes_libelles`,
`masque_prix: false`, `regles` (4 taux), `note_frais_bancaires`.
Sans prix : `sections`/`totaux`/`saisies` vides, `masque_prix: true`.

Écarts d’identité (pas les montants) : `proprietaire_label` absent du payload
validé → null ; `ordre` / `numero` = index du tableau, pas la colonne DB ;
`libelle_affiche` lot = `Lot {numero_lot_commercial}`. Les totaux et KPI
numériques restent ceux du moteur.

## DÉPLOIEMENT-TEST

```
git pull origin main
php artisan optimize:clear
php artisan view:clear
php artisan journal:sync
```

Pas de `route:clear` indispensable (web.php lu à chaque hit OVH) mais autorisé
si les routes restent en cache local. Aucune migration.

Recette :

1. POST données enregistrées APS1 → six chiffres écran identiques.
2. Modifier un prix dans le payload → totaux bougent ; base inchangée.
3. Requête SQL ci-dessus avant/après.
4. Compte sans `foncier.prix` → aucun montant.
5. Scénario gelé → 403.
6. Tiers sans habilitation dossier → 403.
7. Champ hors liste (`parcelles`, `libelle`) → ignoré, jamais persisté.
8. 200 lots : temps mesuré.
9. `seq` renvoyé tel quel.

## LEÇON

Ne jamais brancher un recalcul live sur `updateFiche` / `calculerAffichage` :
le premier écrit, le second `loadMissing` le persisté. Un service dédié qui
prend le payload brut et lance le moteur **une** fois est le seul chemin
sûr. `Gate::before` GS_ADMIN_ERP rend le compte Robin inutilisable pour
prouver un 403 — toujours donner un profil GS_HECTARE sans habilitation
dossier. Lot γ : brancher `Show.vue` en série, debounce + `seq`.
