---
suivi: 1630
date: 2026-09-22
sujet: TVA sur marge réservée à l'enseigne lotissement (HECTARE)
chantier: programmes / financier
type: feature
statut: poussé
hash: 9483d04e
fichiers:
  - app/Support/EnseignesSetup.php
  - app/Support/ProgrammeTvaMargeAcces.php
  - app/Http/Controllers/ProgrammeTvaMargeController.php
  - app/Http/Controllers/BilanFinancierController.php
  - app/Http/Controllers/ProgrammeBudgetController.php
  - database/migrations/2026_09_22_170000_add_tva_marge_module_to_enseignes.php
  - resources/js/Components/Programmes/FinancierSubNav.vue
  - tests/Unit/TvaMargeHectare1630SourceTest.php
  - database/data/journal_mises_a_jour_post_2026_06_12.php
  - public/build/
  - docs/suivi/SUIVI_1630_tva_marge_hectare_seulement.md
---

# SUIVI #1630 — TVA SUR MARGE réservée à HECTARE

## PROMPT ENVOYÉ

Le sous-onglet TVA SUR MARGE (#1628) ne doit s’afficher que pour les programmes
de l’enseigne HECTARE (lotissement) : pas ENVOL (VEFA), pas GEMME (gestion
locative). Masquage **et** contrôle serveur (404 justifié). La restriction
d’enseigne s’ajoute à la restriction de groupes du #1628. Enseigne vide ou
inconnue : ne pas afficher. Compter les données TVA déjà saisies sur des
programmes non HECTARE, sans les supprimer. Ne pas toucher à la page TVA
elle-même, ni à BILAN / BUDGET. Le contrôle ne doit pas passer par un Gate.

## FICHES LUES

- **#1064 LEÇON** — grille lotissement HECTARE : clé dans
  `enseignes.modules_actifs`, helpers `Enseigne::aModule` /
  `Programme::enseigneAModule`, migration **merge** (HECTARE=true,
  ENVOL/GEMME=false), prop Inertia booléenne, aucun `code === 'HECTARE'` dans
  vues ni contrôleurs. Impact : patron repris à l’identique pour `tva_marge`.
- **#1061** — modules CP / CIE / NF : même mécanisme, labels admin via
  `EnseignesSetup::moduleLabels()`.
- **#1184 LEÇON** — un eager load partiel (`enseigne:id,nom`) omet
  `modules_actifs` et `aModule` renvoie toujours false ; `enseigneAModule`
  contient le garde-fou (`refresh()`). Impact : passer par le helper, jamais
  lire `modules_actifs` à la main.
- **#1622 LEÇON** — BUDGET masqué via `m365GroupNames` **brut** côté front ; le
  compte de Robin (GS_ADMIN_ERP) ne prouve aucun masquage.
- **#1628** — la page livrée hier : groupes COMPTA / DG / ADMIN via
  `ProgrammeTvaMargeAcces::peutAcceder`, hors middleware `m365.groups`.
- **#1629 / zones communes** — série Vite : rebase avant build.

## AVANT MODIFICATION (constat)

**Portage de l’enseigne.** `programmes.enseigne_id` → `enseignes` (relation
`Programme::enseigne()`, `BelongsTo`). Table `enseignes` (dump
`hectareenvol.sql`, CREATE TABLE) : `id`, `code`, `nom`, `logo`,
`couleur_primaire`, `couleur_secondaire`, `entite_par_defaut_id`,
`modules_actifs` (JSON **NOT NULL**), `sharepoint_site_id`,
`sharepoint_drive_name`, `actif`, timestamps.

Les **valeurs réelles** (ids 1 = ENVOL, 2 = HECTARE annoncés par Robin) n’ont
**pas** pu être relues en base : MySQL local refuse la connexion
(`SQLSTATE[HY000] [2002]`, même constat #1626 / #1628 / #1629) et le dump est
*structure only* (aucun `INSERT`). Ce qui est **confirmé par le code** :
`EnseignesSetup::seedEnseignes()` crée exactement trois enseignes par `code` —
`ENVOL`, `HECTARE`, `GEMME`. **GEMME est donc bien une enseigne à part
entière** (#932, `modulesGemme()` : `gestion_locative` seul actif, aucune
`entite_par_defaut_id`), pas un mécanisme parallèle. Corollaire : le lot ne
dépend d’aucun id — voir ci-dessous.

**Comment l’enseigne est déjà testée ailleurs.** Jamais par id ni par code :
via le **module d’enseigne**. `Enseigne::aModule(string $key)` lit
`modules_actifs`, et `Programme::enseigneAModule(string $key)` délègue avec le
garde-fou #1184. Usages existants : `CommercialisationController` et
`ProgrammeResumeCommercialBuilder`
(`EnseignesSetup::MODULE_GRILLE_LOTISSEMENT`), `SuiviChantierController` /
`CieController` (`MODULE_COMPTE_PRORATA`, `MODULE_CIE`, `MODULE_NF_HABITAT`).
La règle projet interdit explicitement `=== 'HECTARE'` et `in_array` sur des
codes. **Le code enseigne n’est lu que dans le seed et la migration**
(`defaultModules…PourCode`), jamais à l’exécution.

**Patron de masquage BUDGET (#1622).** `FinancierSubNav.vue` :
`canSeeBudget = m365GroupNames.value.includes('GS_ADMIN_ERP')` (brut, jamais le
helper front ni le flag admin Inertia), et l’item n’est poussé dans `items` que
si le booléen est vrai ; `syncFromLocation()` renvoie sur `#bilan` si le hash
cible un onglet non visible. Côté serveur, `ProgrammeBudgetController` a son
propre `assertBudgetOngletAdminErp()`. C’est ce double patron (masquage + garde
serveur) qui est repris.

## SYNTHÈSE

**Module dédié plutôt que réutilisation de `grille_lotissement`.** Clé
`tva_marge` ajoutée à `enseignes.modules_actifs` (constante
`EnseignesSetup::MODULE_TVA_MARGE`), défauts HECTARE=true, ENVOL=false,
GEMME=false, label admin « TVA sur marge ». Réutiliser
`grille_lotissement` aurait couplé deux fonctionnalités sans rapport (couper la
grille aurait coupé la TVA) ; une clé dédiée reste réglable depuis l’admin
enseignes sans code.

**Migration merge** `2026_09_22_170000_add_tva_marge_module_to_enseignes.php` —
copie du patron #1064 : `mergeModulesDefaults` n’ajoute que les clés manquantes,
ne touche pas aux bascules existantes, `down()` retire la clé. Aucun DDL, aucune
suppression de données.

**Décision d’éligibilité.** `ProgrammeTvaMargeAcces::enseigneEligible($programme)`
= `$programme->enseigneAModule(MODULE_TVA_MARGE)`, et
`peutVoirOnglet($user, $programme)` = `peutAcceder($user) && enseigneEligible($programme)`.
Les deux conditions sont **cumulées** : la restriction de groupes du #1628 est
intacte.

**Contrôle serveur — 404 assumé.** `ProgrammeTvaMargeController::index()` et
`update()` appellent `assertEnseigneEligible()` qui fait
`abort_unless(enseigneEligible, 404)`. **404 et non 403** : sur un programme
ENVOL ou GEMME cette page n’existe pas, ce n’est pas un refus de droit — un 403
laisserait entendre qu’une habilitation permettrait d’y accéder. La garde de
groupes (#1628) reste un 403, exécutée après. Contrôle direct par
`abort_unless`, **aucune autorisation Laravel** : le court-circuit admin ERP
(GS_ADMIN_ERP) ne peut donc pas l’annuler.

**Masquage front.** Les trois pages qui montent la barre exposent une prop
serveur : `BilanFinancierController::index` et `ProgrammeBudgetController::index`
passent `tva_marge_disponible => ProgrammeTvaMargeAcces::enseigneEligible($programme)`,
`ProgrammeTvaMargeController::index` passe `true` (la page n’est servie que si
éligible). `FinancierSubNav.vue` :
`tvaMargeDisponible = page.props.tva_marge_disponible === true`, combiné aux
groupes. **Prop absente = onglet masqué** (fail-closed) ; aucun code ni id
d’enseigne dans le front.

### Cas enseigne vide / inconnue
`enseigneAModule` renvoie false si la relation est nulle (enseigne_id NULL ou
introuvable), si la clé est absente de `modules_actifs`, ou si elle vaut false.
Tous ces cas masquent l’onglet et renvoient 404 sur l’URL directe. Conséquence
assumée de la migration : **tant que `migrate --force` n’est pas passé**, la clé
est absente partout et l’onglet est masqué même sur HECTARE — la migration est
donc obligatoire au déploiement.

### Données TVA déjà saisies hors HECTARE
**Aucune donnée supprimée** (le lot ne touche pas `programme_tva_marges` : ni
schéma, ni lecture, ni écriture). Les lignes éventuelles restent en base,
simplement inaccessibles par l’UI et par l’URL.

Le **comptage n’a pas pu être exécuté** : la base locale refuse la connexion
(constat ci-dessus) et aucun accès prod depuis l’environnement de
développement. Borne haute raisonnée : la table `programme_tva_marges` est créée
par la migration du **#1628, poussée le 22/09** (le jour même), les montants ne
sont saisissables qu’à la main, et les seuls comptes concernés sont
COMPTA / DG / ADMIN ERP — le volume attendu est **0 ou quelques lignes**.
Requête à exécuter en prod (lecture seule) :

```sql
SELECT COALESCE(e.code, '(sans enseigne)') AS enseigne, COUNT(*) AS lignes
FROM programme_tva_marges t
JOIN programmes p ON p.id = t.programme_id
LEFT JOIN enseignes e ON e.id = p.enseigne_id
GROUP BY COALESCE(e.code, '(sans enseigne)')
ORDER BY lignes DESC;
```

## TABLEAU DE MAPPING

| Enseigne | Identifiant (`enseignes.code`) | `modules_actifs['tva_marge']` | Onglet TVA SUR MARGE visible | Accès direct `/programmes/{id}/tva-marge` |
|---|---|---|---|---|
| HECTARE (lotissement) | `HECTARE` (id 2 en prod selon Robin) | `true` (migration) | **Oui**, si le compte est COMPTA / DG / ADMIN ERP (#1628) | 200 si groupe OK ; **403** si groupe absent |
| ENVOL (VEFA) | `ENVOL` (id 1 en prod selon Robin) | `false` | **Non**, quel que soit le compte | **404** |
| GEMME (gestion locative) | `GEMME` | `false` | **Non**, quel que soit le compte | **404** |
| Enseigne absente / inconnue | `enseigne_id` NULL ou orphelin | clé absente → `false` | **Non** | **404** |

Le lot ne compare **aucun** id : la colonne `enseigne_id` n’apparaît nulle part
dans la décision, seule la clé de module compte.

## VÉRIFICATIONS

`php -l` : OK sur les 7 fichiers PHP (+ journal).

Reflection / DI : les 3 contrôleurs instanciables via le conteneur ; matrice
d’éligibilité vérifiée à l’exécution (module true → true ; false → false ; clé
absente → false ; enseigne nulle → false ; HECTARE true / ENVOL false / GEMME
false). Sortie brute dans la synthèse.

Tests : `TvaMargeHectare1630SourceTest` (8 tests) +
`TvaMarge1628SourceTest` + `OngletFinancier1622SourceTest` → **21 passed
(207 assertions)**. `ProgrammeEnseignePartialLoad1184Test` +
`NomenclatureParEnseigneTest` → 5 passed, 3 skipped (sqlite).

⚠️ Piège rencontré (récidive du #1628) : les tests source interdisent la chaîne
`Gate::` dans ces fichiers — un **commentaire** la contenant fait échouer le
test. Commentaires reformulés (« autorisation Laravel », « court-circuit admin
ERP »).

## DÉPLOIEMENT-TEST

```bash
git pull origin main
php artisan migrate --force   # OBLIGATOIRE : sans elle, l'onglet est masqué partout
php artisan optimize:clear
php artisan view:clear
php artisan journal:sync
```

Pas de `route:clear` (aucune route ajoutée ou modifiée). Pas de `npm`, pas de
`composer` sur OVH.

Tests :
1. Programme HECTARE (ex. HEC34-001) + compte COMPTA / DG / ADMIN : onglet
   visible, page accessible, saisie inchangée.
2. Programme ENVOL (ex. ENV34-001), même compte : onglet **absent**, accès
   direct → **404**.
3. Programme GEMME s’il en existe : onglet absent, URL directe 404.
4. Programme sans enseigne : onglet absent, URL directe 404.
5. Compte hors COMPTA / DG / ADMIN sur un programme HECTARE : onglet absent
   (restriction #1628 toujours active), URL directe → 403.
6. BILAN et BUDGET inchangés sur les trois enseignes.
7. **Compte de Robin (GS_ADMIN_ERP) sur un programme ENVOL : onglet ABSENT et
   URL directe 404.** C’est le test qui prouve que le contrôle ne passe pas par
   une autorisation Laravel.

## LEÇON

Restreindre une fonctionnalité à une enseigne ne se fait ni par id ni par code,
mais par une **clé de `modules_actifs`** : le code enseigne n’est lu que dans le
seed et la migration de défauts. Effet de bord à annoncer : la clé absente vaut
« inactif », donc **la migration de merge est un prérequis de déploiement**, pas
un détail — sans elle la fonctionnalité disparaît aussi pour l’enseigne censée
la garder.

Pour qu’une barre de sous-onglets masque un onglet selon une règle métier
serveur, il faut que **chaque page portant la barre** expose la prop (ici trois
pages) ; sinon la prop manque sur une page et l’onglet disparaît ou réapparaît
selon celle qu’on visite. Le défaut fail-closed (`=== true`) rend cet oubli
visible sans ouvrir d’accès.
