---
suivi: 1448
date: 2026-09-04
sujet: Dossier foncier — postes budgétaires (onglet Facturation)
chantier: foncier / facturation dossiers
type: feature
statut: poussé
hash: 8e32798a
fichiers:
  - database/migrations/2026_09_04_144800_add_dossier_foncier_id_to_postes_budgetaires.php
  - app/Models/PosteBudgetaire.php
  - app/Models/Scopes/PosteBudgetaireProgrammeScope.php
  - app/Models/DossierFoncier.php
  - app/Services/Foncier/DossierFoncierPosteBudgetaireService.php
  - app/Http/Controllers/Foncier/DossierFoncierPosteBudgetaireController.php
  - app/Http/Controllers/Foncier/DossierFoncierController.php
  - app/Support/Foncier/DossierFoncierTabs.php
  - routes/web.php
  - resources/js/Composables/useTabAnchor.js
  - resources/js/Layouts/AppShell.vue
  - resources/js/Pages/Foncier/Dossiers/Show.vue
  - resources/js/Components/Foncier/DossierFacturationPanel.vue
  - tests/Unit/DossierFoncier1448SourceTest.php
  - database/data/journal_mises_a_jour_post_2026_06_12.php
  - public/build/
  - docs/suivi/SUIVI_1448_dossier_foncier_postes_budgetaires.md
---

# SUIVI #1448 — Postes budgétaires sur dossier foncier

## PROMPT ENVOYÉ

Deuxième lot du chantier facturation dossiers (#1443) : postes budgétaires
sur le dossier, onglet Facturation. Pas de dépôt, destination PROJETS fermée,
pas de flux comptable. Tous les types actifs de l’enseigne, aucune retenue.
Structure compatible avec une bascule dossier → programme / bilan.

## FICHES LUES

- `#1445` LEÇON — classe Sage : pas de filtre enseigne sur Intacct ; `$groupesM365`
  conservé dans `index()` (#1442). Réutilisé : même fiche Show, `index()` non
  retouché.
- `#1442` LEÇON — ne jamais réassigner `$groups` avec `m365GroupNames()`.
- `#1443` — acquis (non remesuré) : UNIQUE (programme_id, poste_type_id),
  programme_id NOT NULL, 66 types HECTARE actifs, ids 119/120 sans compte.
- `#1434` — `GS_GEMME_PDG` dans RecordAcl / `m365GroupNames()` brut.
- `#1196` — onglets : `DossierFoncierTabs` + `DOSSIER_FONCIER_TAB_KEYS` +
  AppShell. Pas de droit par onglet : `authorize('view')` puis tous les onglets.
- `#1421` — `after()` interdit dans `Schema::create`, valide dans `Schema::table`.
- `#1271` / `#1444` — pas d’`overflow-x-hidden`.
- `#1446` / `#1447` — fiche parcelle, non touchée.

## PRÉREQUIS

`origin/main` = `0ea7fb8e` (chore #1447). #1445 réel : `d5ce3e2d` / fiche
`be9d130f` (le hash `43ea9e05` du prompt n’existe pas dans le dépôt).
#1446 `ad743a5c` et #1447 `8df835c6` sont des ancêtres ; ils touchent
`Parcelles/Show.vue`, pas le dossier.

## ÉTAPE 0 — STRUCTURE

Option retenue : **a)** `programme_id` nullable + `dossier_foncier_id` sur
`postes_budgetaires`.

Justification bascule : les dépenses devront un jour entrer dans le BILAN du
programme. `BilanFinancierService` joint déjà `postes_budgetaires` sur
`programme_id`. Même table, mêmes ids → à la bascule :

```
UPDATE postes_budgetaires
SET programme_id = :id_programme, dossier_foncier_id = NULL
WHERE dossier_foncier_id = :id_dossier;
```

Les lignes deviennent visibles au bilan sans copie ni remap de FK. XOR
(`pb_parent_xor`) : une ligne a exactement un parent.

Option b) (table parallèle) : isolation parfaite des 111 programmes, mais
bascule = INSERT + nouveaux ids ; les factures du lot suivant devraient
être remappées. Rejeté.

Option c) (type seul, pas d’enveloppe) : pas de `montant_etude_fi` aujourd’hui
ni de ligne bilan demain sans création de postes. Rejeté.

Piège NULL ≠ NULL : l’UNIQUE existant `(programme_id, poste_type_id)` est
**conservé** (pas de `dropUnique`, donc pas de risque 1553). Il continue
d’empêcher les doublons programme (programme_id non NULL). Il n’empêche PAS
deux lignes dossier `(NULL, même type)` — c’est le piège. Traitement : second
UNIQUE `pb_dossier_type_uq (dossier_foncier_id, poste_type_id)`, efficace dès
que `dossier_foncier_id` est non NULL. Pas de colonne générée STORED : le
pattern #1096 sert un UNIQUE *partiel* (détention courante) ; ici deux
parents exclusifs → deux uniques, chacun sur le côté renseigné.

Isolation 111 programmes : scope Eloquent `PosteBudgetaireProgrammeScope`
(`whereNotNull(programme_id)`). Sans lui, `DepotFactureController::buildPostesChargeDirecteParProgrammePayload`
(get() sans filtre) rangerait les lignes dossier sous `programme_id = 0`.
Ce contrôleur n’est **pas** modifié. Les `DB::table` du bilan joignent déjà
`pb.programme_id = :id`.

## SYNTHÈSE

Onglet Facturation (mécanisme #1196, pas de droit par onglet). CRUD postes
`montant_etude_fi` + commentaire. Nomenclature : types **actifs** de
l’enseigne, **sans** `applicable_budget`. Rubriques via `rubrique_bilan.libelle`
(sinon strip `^[a-k]\s+`). Avertissement si `compte_comptable_id` NULL
(119/120 inclus, pas de blocage). Garde-fou retrait si facture (colonnes
futures `poste_budgetaire_id` / `dossier_foncier_id`). Emplacement factures
en attente. Écritures : `authorize('update')` + middleware `foncier.gestion`.

## DÉPLOIEMENT-TEST

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

## LEÇON

Rendre `programme_id` nullable sans 2e UNIQUE dossier = doublons (NULL ≠ NULL).
Un scope Eloquent sur la table partagée évite de toucher le dépôt pour empêcher
un `programme_id = 0` fantôme. `index()` dossier : ne pas retoucher `$groups`.
