---
suivi: 1632
date: 2026-09-22
sujet: Ventilation d'un lot en sous-lots et calcul de la TVA
chantier: commercialisation_grille
type: feature
statut: poussé
hash: de742ff6
fichiers:
  - app/Support/Commercial/SousLotReferentiels.php
  - app/Models/LotSousLot.php
  - app/Models/LotCommercial.php
  - app/Services/Commercial/LotSousLotTvaService.php
  - app/Http/Controllers/LotSousLotController.php
  - app/Http/Controllers/Concerns/ManagesCommercialLotPayload.php
  - app/Http/Controllers/CommercialisationController.php
  - app/Support/Commercial/StockSyntheseCalculator.php
  - database/migrations/2026_09_22_190000_create_lot_sous_lots_table.php
  - resources/js/Components/Commercialisation/SousLotsVentilationDialog.vue
  - resources/js/Components/Commercialisation/LotsGrillePanel.vue
  - routes/web.php
  - tests/Unit/SousLotsTva1632SourceTest.php
  - database/data/journal_mises_a_jour_post_2026_06_12.php
  - public/build/
  - docs/suivi/SUIVI_1632_sous_lots_tva.md
---

# SUIVI #1632 — sous-lots et TVA (marge / totale / sans TVA × 20 % / 10 %)

## PROMPT ENVOYÉ

Livrer la ventilation d'un lot de grille en sous-lots et le calcul de la TVA.
Parent calculé (somme des enfants, non saisissable), on vend le parent, enfants
jamais dans les totaux. Chaque sous-lot : surface, SDP, CES, prix TTC, type de
zone, MODE de TVA et TAUX — mode et taux indépendants. Dénominateur du PR
foncier = surfaces moins sous-lots non constructibles moins lots ODF. Calcul
automatique non modifiable, « non calculé » si PR absent ou dénominateur nul.
Ne pas toucher à la page TVA sur marge, aux statuts, aux réservations ; ne pas
corriger l'appel de fonds (le signaler).

## FICHES LUES

- **#1626** — grille = `lots_commerciaux` / `LotCommercial` ; `regime_tva` est un
  TAUX (`tva_20|tva_10|tva_55|exonere`) et non un mode ; absentes : `parent_id`,
  `type_zone`, TVA figée, `pr_foncier`. Signale le ÷1,20 en dur de l'appel de
  fonds. Impact : mode et taux dans deux colonnes neuves, `regime_tva` intact.
- **#1628** — `programme_tva_marges` (`pr_foncier`, `frais_notaire`, HT,
  `tranche_id` NULL) ; page en lecture seule pour ce lot.
- **#1064** — module enseigne `grille_lotissement` : c'est lui qui conditionne
  l'affichage des colonnes surface/SDP/CES, donc le bouton de ventilation.
- **#1029 / #1173** — exclusivité marché/enveloppe et « jamais de prix_vente à
  la création » : un sous-lot ne porte donc ni statut ni prix de vente.
- **#1158 / #1494** — `StockSyntheseCalculator` = source unique écran + export.

## AVANT MODIFICATION (constat)

### Colonnes de `lots_commerciaux`
`Schema::getColumnListing('lots_commerciaux')` **n'a pas pu être exécuté** :
MySQL local refuse la connexion (`SQLSTATE[HY000] [2002]`, constat récurrent
#1626 / #1628 / #1629 / #1630 / #1631). Colonnes relevées sur le dump
`hectareenvol.sql` (CREATE TABLE L2564-2594) **et** croisées avec
`LotCommercial::$fillable` / `casts()` :

`id`, `programme_id`, `numero_lot`, `type_logement`, `etage`,
`surface_habitable`, `surface_terrasse`, `surface_terrain`, `sdp`, `ces`,
`exposition`, `nb_stationnement`, `plan_fichier_path`, `prix_grille_ttc`,
`prix_vente_ttc`, `difference`, `statut` (enum en_stock|en_vente|reserve|acte|
annule|dation), `mode_vente`, `tma_montant_ttc`, `remises_ttc`, `agence_nom`,
`commission_ttc`, `commentaire`, `date_acte`, `date_reservation`, `created_at`,
`updated_at`, **`deleted_at`**, `regime_tva` (enum tva_20|tva_10|tva_55|exonere).

Confirmé : **aucun** `parent_id`, `type_zone`, `pr_foncier`, TVA figée.

### Emplacements UI
- Modal « Modifier la grille du lot » :
  `resources/js/Components/Commercialisation/LotsGrillePanel.vue`
  — en-tête L3148 (`:header="editingLotId ? 'Modifier la grille du lot' : 'Nouveau lot'"`),
  bloc lotissement Surface / SDP / CES / Prix de grille L3166-3215,
  pied de modal (boutons) L3427-3438.
- Édition en ligne « Nouvelle ligne — Tab pour naviguer, Entrée pour
  enregistrer, Échap pour annuler » : même fichier, L2900 (bandeau) et
  `submitInlineCreate` / `cancelInlineCreate` L2993-3012.

### `StockSyntheseCalculator`
`app/Support/Commercial/StockSyntheseCalculator.php`, méthode statique
`calculer(Collection $lots, float $remisesAccordees)`. Appelée **exactement
deux fois**, toutes deux dans `CommercialisationController` (écran L85, export
L257) — un test source verrouille ce compte. Elle reçoit une collection de
`LotCommercial` et exclut `STATUTS_HORS_STOCK`.

**Comment exclure proprement les enfants** : ne pas les faire entrer dans
`lots_commerciaux`. Voir la décision de structure ci-dessous — l'exclusion
devient structurelle, pas un filtre à répéter.

### 🔴 Appel de fonds : HT calculé en dur (SIGNALÉ, NON CORRIGÉ)
`app/Services/GenerationAppelFondService.php` **ligne 128** :

    $montantHt = round($montantTtc / 1.20, 2);

Le HT est déduit d'un taux 20 % figé, sans jamais lire `regime_tva`. Deux autres
÷1,20 existent hors périmètre (`TresorerieService` L346 et L370,
`EcrituresComptablesCalculator` L400 pour la base globale).

**Ce que cela devient quand un lot portera plusieurs régimes ET plusieurs
taux** : un appel de fonds sur un lot ventilé mélangeant, par exemple, un
sous-lot en TVA totale 20 % et un sous-lot en TVA sur marge 10 % n'a plus de
taux unique. Le ÷1,20 produira un HT **faux dans les deux cas** (trop bas pour
le 10 %, et inapplicable au mode marge où la base est la marge et non le prix).
Le HT devra être obtenu en **sommant les HT calculés par sous-lot** (ce que
`LotSousLotTvaService` fournit déjà), au prorata de l'échéance appelée — jamais
par une division globale. Correction hors de ce lot, comme demandé.

## SYNTHÈSE

### Structure : table dédiée plutôt que `parent_lot_id`
Les sous-lots vivent dans une table **`lot_sous_lots`** et non comme lignes de
`lots_commerciaux`. Justification directement issue de l'arbitrage « on vend le
parent, les enfants ne sont que du détail de composition » : un sous-lot n'est
pas un lot vendable, il n'a donc ni statut commercial, ni prix de vente, ni
réservation. Le mettre hors de la table des lots rend le double comptage
**structurellement impossible** — stock, En vente / Réservé / Acté, Vente
totale, Avancement, export grille, pipeline de réservation, tableaux de bord :
aucune requête existante ou future sur `lots_commerciaux` ne peut voir un
sous-lot. Une auto-référence aurait imposé de retrouver et filtrer
`parent_lot_id IS NULL` dans **chaque** requête de l'ERP, c'est-à-dire le risque
principal du lot converti en dette permanente. « Un seul niveau » est acquis de
la même façon : la FK pointe `lots_commerciaux`, jamais `lot_sous_lots`.

Migration `2026_09_22_190000_create_lot_sous_lots_table.php` : idempotente
(`Schema::hasTable` puis `hasColumn` pour compléter), **aucune colonne générée
STORED** (MySQL 8 la refuse après une colonne portant une FK — erreur 1215,
incident du 17/09), FK nommée court `lot_sous_lots_lot_fk` (limite 64
caractères), aucun DDL en transaction.

### Mode et taux : deux colonnes
`tva_mode` (`marge` | `totale` | `sans_tva`) et `tva_taux_code`
(`tva_20` | `tva_10` | **NULL**). Deux colonnes et non un référentiel de
couples parce que le calcul consomme les deux dimensions séparément : le mode
choisit la formule, le taux y entre comme variable `t`. Un code unique
« marge_20 » obligerait à re-parser la chaîne à chaque calcul et à créer une
valeur nouvelle par taux futur (5,5 %, 2,1 %) ; avec deux colonnes, ajouter un
taux n'ajoute aucune combinaison à maintenir. `regime_tva` n'est **pas**
détourné : il ne porte qu'un taux et ne saurait distinguer « marge 20 % » de
« totale 20 % ».

**Sans TVA** : le taux est sans objet → `tva_taux_code` **NULL imposé** par
`SousLotReferentiels::normaliserTauxCode()` côté serveur (une valeur envoyée par
le client est écrasée), et le select est désactivé côté écran. À l'inverse, mode
marge ou totale **sans** taux → 422.

### Parent calculé
`LotSousLotTvaService::recalculerParent()` écrit sur le parent
`surface_terrain`, `sdp`, `ces`, `prix_grille_ttc` = sommes des enfants, à
chaque création / modification / suppression de sous-lot. Toutes les vues, tous
les totaux et l'export continuent donc de lire les colonnes habituelles, avec la
valeur sommée.

Exemple du lot 1 de L'AURORE : 138 m² saisis ; deux enfants de 60 et 40 m² →
le parent passe à **100 m²**, et l'écran le dit avant validation (bandeau ambre
dans le modal : « la valeur saisie initialement est **remplacée** »).

Contrôle serveur : `assertChampsCalculesNonModifies()` dans
`ManagesCommercialLotPayload` refuse en 422 tout PATCH modifiant ces quatre
champs sur un lot ventilé (tolérance ±0,005 pour la valeur identique renvoyée
par le formulaire). L'UI grise les champs ; le serveur ne s'y fie pas.

**Suppression du dernier enfant** : le parent **conserve la dernière somme** et
redevient saisissable. Choix assumé : restaurer une valeur d'avant ventilation
ferait réapparaître un chiffre que le détail venait de contredire, sans trace
pour l'utilisateur ; conserver la somme laisse une valeur cohérente avec le
dernier état validé, librement modifiable ensuite.

### Calcul de la TVA
Taux `t` **toujours** issu du sous-lot via `SousLotReferentiels::taux()` :
aucune constante de taux dans le service (assertion de test).

- **TVA totale** : `TVA = TTC × t ÷ (1 + t)` ; `HT = TTC − TVA`
- **TVA sur marge** : `PR_lot = PR_programme × surface ÷ surface_ref` ;
  `TVA = (TTC − PR_lot) ÷ (1 + t) × t` ; `HT = TTC − TVA`
- **Sans TVA** : `TVA = 0` ; `HT = TTC`

`PR_programme = pr_foncier + frais_notaire` lus dans `programme_tva_marges`
(HT, `tranche_id` NULL) — page #1628 non modifiée.

**Dénominateur** (`surfaceReference`) : Σ des surfaces du programme, moins les
sous-lots non constructibles, moins les lots `mode_vente = odf` :
lot ventilé → Σ des surfaces de ses sous-lots **constructibles** ; lot non
ventilé → sa `surface_terrain`. `lots_commerciaux.deleted_at IS NULL` filtré
explicitement. Conséquence assumée conservée : un sous-lot **non
constructible** en TVA sur marge reçoit un PR alloué **nul**, donc une TVA
calculée sur la totalité de son prix.

**Jamais de zéro silencieux** : PR non saisi → `calculable = false` +
motif « PR foncier non saisi… » + lien vers FINANCIER → TVA SUR MARGE ;
dénominateur nul → `calculable = false` + motif, **aucune division**. L'écran
affiche « non calculé » en rouge, jamais 0.

**Arrondis** : la TVA est arrondie au centime (`round(, 2)`) et le HT est déduit
par soustraction `TTC − TVA` (aucun second arrondi). Le parent affiche la
**somme des TVA déjà arrondies** des enfants : l'égalité au centime est donc
exacte par construction, et `Σ TVA + Σ HT = TTC parent`.

### Totaux préservés
`StockSyntheseCalculator` est documenté « SEULS LES PARENTS SONT COMPTÉS » et
n'accède à aucun sous-lot (ni modèle, ni relation — assertions de test). Aucune
autre modification n'a été nécessaire : les sous-lots n'existent pas dans
`lots_commerciaux`, donc ni l'écran, ni l'export, ni le pipeline de réservation,
ni les KPI ne peuvent les compter. Le parent, lui, porte déjà leur somme.

### Interface
Bouton **« Informations avancées »** dans le pied du modal d'édition d'un lot
(lot existant, grille lotissement) → `SousLotsVentilationDialog.vue` :
tableau des sous-lots (surface, SDP, CES, prix TTC, zone, TVA, **PR alloué**,
TVA calculée, HT), pied de tableau « Total (= le lot parent) », formulaire
d'ajout / modification, suppression. TVA et HT en lecture seule. Mode et taux en
**deux listes séparées** (et non un couple figé) : c'est la forme qui rend les
cinq combinaisons atteignables sans multiplier les libellés, et le libellé du
couple (« TVA sur marge 10 % (social) ») est affiché dans la colonne TVA du
tableau — lisibilité sans perte d'expressivité.

Un lot **sans** sous-lot : édition en ligne et modal strictement inchangés
(aucun champ désactivé, aucun bandeau).

## TABLEAU DE MAPPING OBLIGATOIRE

### Type de zone
| Libellé affiché | Valeur postée | Valeur stockée (`lot_sous_lots.type_zone`) | Libellé back |
|---|---|---|---|
| Constructible | `constructible` | `constructible` | `SousLotReferentiels::ZONE_CONSTRUCTIBLE` |
| Non constructible | `non_constructible` | `non_constructible` | `SousLotReferentiels::ZONE_NON_CONSTRUCTIBLE` |

### Mode de TVA
| Libellé affiché | Valeur postée | Valeur stockée (`tva_mode`) | Libellé back |
|---|---|---|---|
| TVA sur marge | `marge` | `marge` | `MODE_MARGE` |
| TVA totale | `totale` | `totale` | `MODE_TOTALE` |
| Sans TVA | `sans_tva` | `sans_tva` | `MODE_SANS_TVA` |

### Taux
| Libellé affiché | Valeur postée | Valeur stockée (`tva_taux_code`) | t utilisé |
|---|---|---|---|
| 20 % | `tva_20` | `tva_20` | 0,20 |
| 10 % (social) | `tva_10` | `tva_10` | 0,10 |
| (désactivé, « Sans objet ») | `null` | **NULL** | — |

### Combinaisons mode × taux (toutes)
| Libellé du couple | `tva_mode` | `tva_taux_code` | Formule appliquée |
|---|---|---|---|
| TVA sur marge 20 % | `marge` | `tva_20` | `(TTC − PR_lot) ÷ 1,20 × 0,20` |
| TVA sur marge 10 % (social) | `marge` | `tva_10` | `(TTC − PR_lot) ÷ 1,10 × 0,10` |
| TVA totale 20 % | `totale` | `tva_20` | `TTC × 0,20 ÷ 1,20` |
| TVA totale 10 % (social) | `totale` | `tva_10` | `TTC × 0,10 ÷ 1,10` |
| Sans TVA | `sans_tva` | NULL | `TVA = 0 ; HT = TTC` |

## VÉRIFICATIONS

`php -l` : OK sur les 10 fichiers PHP + le journal.

Reflection / DI + calculs exécutés (sortie brute en synthèse) : service et
contrôleur résolus par le conteneur, `LotSousLot` sur `lot_sous_lots`, et les
exemples de Robin vérifiés chiffre par chiffre (90 000 TTC → 15 000 / 75 000 ;
110 000 TTC à 10 % → 10 000 / 100 000 ; marge 10 % → PR 20 000, TVA 8 181,82).

Tests : `SousLotsTva1632SourceTest` (13 tests) + #1494 / #1499 / #1628 / #1630
→ **42 passed (394 assertions)**. `GrillePrixExport1494Test` et `1499Test`
relancés classe par classe : 8 et 11 passed. ⚠️ Lancer les deux classes d'export
dans le même processus que d'autres tests provoque un « Premature end of PHP
process » (génération Excel + PDF en série) : artefact d'environnement
préexistant, pas une régression — chaque classe passe seule.

⚠️ **Aucun test avec base** : `pdo_sqlite` est absent de cet environnement. La
migration, les endpoints CRUD et le recalcul du parent n'ont donc **pas** été
exécutés contre une base. Le contrôle en recette est indispensable (tests 1, 12
et 13 en particulier).

## DÉPLOIEMENT-TEST

```bash
git pull origin main
php artisan migrate --force
php artisan optimize:clear
php artisan view:clear
php artisan route:clear
php artisan journal:sync
```

Migration obligatoire (table `lot_sous_lots`). Pas de `npm` / `composer` sur OVH.

1. Créer deux sous-lots sur un lot : le parent devient la somme, ses quatre
   champs sont grisés avec le bandeau d'explication.
2. Sous-lot constructible, TVA totale 20 %, 90 000 € TTC → TVA 15 000 €,
   HT 75 000 €.
3. Sous-lot TVA totale 10 %, 110 000 € TTC → TVA 10 000 €, HT 100 000 €.
4. Sous-lot TVA sur marge 10 % : PR alloué affiché, TVA calculée avec t = 0,10.
5. Sous-lot non constructible en TVA sur marge : PR alloué 0, TVA sur la
   totalité.
6. Sous-lot constructible en TVA sur marge : PR au prorata, TVA conforme.
7. Programme sans PR foncier : « non calculé » + lien vers la page, jamais 0.
8. Tous les sous-lots non constructibles : message dénominateur nul.
9. Somme des TVA des enfants = TVA du parent au centime.
10. Bandeau stock, export grille, réservations : aucun sous-lot compté.
11. Lot sans enfant : édition en ligne et modal identiques à avant.
12. Supprimer tous les enfants : parent saisissable, valeurs = dernière somme.
13. Lot en `mode_vente = odf` : exclu du dénominateur (vérifier le compteur
    « en ODF exclu(s) » du bandeau de la modale).
14. Mélanger 20 % et 10 % dans un même lot : chaque TVA avec son taux, parent =
    somme.

## LEÇON

Quand une entité nouvelle ne doit **jamais** être comptée par l'existant, le
choix de table est un choix de sécurité, pas de modélisation : une table dédiée
rend l'exclusion structurelle, là où une auto-référence transforme « ne pas
compter les enfants » en filtre à ne jamais oublier dans chaque requête, y
compris celles qui n'existent pas encore.

Mode et taux de TVA sont deux dimensions : toute colonne unique (`regime_tva`
ici) est condamnée dès qu'un second mode apparaît. Deux colonnes coûtent une
migration, un code unique coûte un parsing à chaque calcul et une valeur
nouvelle par taux.

Le ÷1,20 en dur de l'appel de fonds n'est pas un détail cosmétique : dès qu'un
lot porte plusieurs modes et plusieurs taux, aucune division globale ne peut
produire un HT juste. Le HT devra être la somme des HT par sous-lot.
