---
suivi: 1029
date: 2026-07-28
sujet: Ancrage programme_budget_id + contrôles stricts rattachement (sous-lot 2a, UI inchangée)
chantier: budgets-programme
type: fix
statut: poussé
hash: ca232978
fichiers:
  - database/migrations/2026_07_28_210000_add_programme_budget_id_to_depot_factures_and_factures_table.php
  - app/Models/DepotFacture.php
  - app/Models/Facture.php
  - app/Models/ProgrammeBudget.php
  - app/Services/FactureMarcheDoubleAncrageService.php
  - app/Support/ProgrammeChargeAccountResolver.php
  - app/Http/Controllers/DepotFactureController.php
  - app/Http/Controllers/ComptabilisationController.php
  - database/data/journal_mises_a_jour_post_2026_06_12.php
  - docs/suivi/SUIVI_1029_ancrage_budget_controles.md
---

## PROMPT ENVOYÉ

SUIVI #1029

## CONTEXTE
ERP Hectarion (repo HECTAREG/erp-immo, prod envol.hectare.fr, OVH mutualisé cluster113).
AVANT DE CODER : `git fetch origin && git checkout main && git pull origin main`, puis coller
`git log --oneline -1 origin/main`.

⚠️ D'autres lots sont en cours dans le même arbre de travail (#1027 Aktor, #1028 tâches
drag & drop). Ce lot ne touche NI le module Assistant/Aktor, NI le module tâches de suivi.
Vérifier avant commit que `git status --short` ne mêle pas des fichiers de ces lots, et
n'ajouter au commit QUE les fichiers de ce périmètre.

Chantier BUDGETS PROGRAMME. Déjà en prod :
- **#1023** : flags `postes_budgetaires_types.applicable_marche` / `.applicable_budget`
  (`applicable_charge_directe` a été RENOMMÉ en `applicable_budget`). 42 postes ENVOL classés :
  21 marché / 21 budget.
- **#1025** : tables `programme_budgets` (N enveloppes par programme × poste : `libelle`,
  `montant_ht`, `actif`, softdeletes) + `programme_budget_revisions` (append-only),
  `ProgrammeBudgetService`, onglet BUDGET, droits DG + Admin ERP.
- **#1026** : diagnostic complet du bloc de rattachement
  (`docs/suivi/SUIVI_1026_diag_rattachement_logique_a.md`) — À LIRE avant de coder, il
  contient la cartographie exacte avec les numéros de lignes.

**Ce lot = SOUS-LOT 2a du découpage validé** : données + contrôles + double ancrage.
**L'INTERFACE UTILISATEUR RESTE INCHANGÉE.** Les 2 cases à cocher restent en place et
fonctionnent comme aujourd'hui. On pose le socle back sans rien casser de visible.
Les sous-lots suivants (2b front logique A, 2c migration des données, 2d nettoyage) NE SONT
PAS dans ce lot — NE RIEN ANTICIPER.

## FAITS PROD VÉRIFIÉS (ne pas re-supposer)
- Le fichier de saisie est `resources/js/Pages/DepotFactures/DepotFactureSaisieForm.vue`
  (~9101 lignes) — PAS sous `Pages/Factures/`.
- `factures` n'a AUCUN ancrage poste (ni `poste_budgetaire_type_id`, ni `sans_marche`) ;
  seul `depot_factures` les porte.
- `rattachement_poste_direct` n'existe QUE dans le JSON `ocr_raw.saisie`, aucune colonne.
- `sans_marche` est une colonne, LUE en de nombreux endroits (saisie, préremplissage liste,
  alertes, rapprochement, commande d'alignement, garde d'identité, historique).
- Trou de validation confirmé : `assertRattachementPosteChargeDirecteValide` fait un
  early-return si le poste est absent (≈L4258-4260), et les flux `valider`,
  `validerHistorique`, `resolveEngagementPayloadPourValiderDejaValide`,
  `resolveEngagementPayloadPourValiderEtComptabiliser` ne l'appellent PAS du tout.
- Retenues (RG/CP/OPC/Finitions) affichées dès `destination === PROGRAMMES`, **sans garde
  hors-marché** (≈L7702-7718). Audit prod : 19 dépôts en rattachement poste, tous avec
  RG = 0 et CP = 0 → aucune donnée à corriger, prévention uniquement.
- `programmes.libelle` (JAMAIS `.nom`), `marches.intitule`, `fournisseurs.nom`.

## DÉCISIONS ARBITRÉES PAR ROBIN (à appliquer, ne pas rediscuter)
1. **Double ancrage confirmé** : `programme_budget_id` sur `depot_factures` ET `factures`.
   `poste_budgetaire_type_id` reste sur le dépôt seul (dérivable de l'enveloppe ou du marché).
2. **`sans_marche` est CONSERVÉE telle quelle** : elle porte le sens « marché à créer ».
   C'est `rattachement_poste_direct` (JSON) qui disparaîtra au profit de
   `programme_budget_id` non nul. **Pas d'enum de mode**, pas de migration sémantique.
3. **Poste obligatoire dans tous les cas de rattachement**, y compris « marché à créer ».
4. **Retenues interdites en mode budget** : blocage STRICT, conditionné au mode réel de
   rattachement et non à la seule destination.
5. **Poste sans compte comptable → blocage à la comptabilisation** avec message clair.
   PAS de repli silencieux. Le compte `471000` reste réservé au cas « marché à créer ».
6. Pas de bascule vers une enveloppe depuis la fiche marché (`Marches/Show.vue`) : hors
   périmètre, à neutraliser proprement plutôt qu'à faire fonctionner.

## À FAIRE

### 1. Migration — colonnes d'ancrage
- `depot_factures.programme_budget_id` : unsignedBigInteger nullable, FK →
  `programme_budgets`, **nullOnDelete**, INDEX.
- `factures.programme_budget_id` : idem.
- Aucun backfill de données dans ce lot (c'est le sous-lot 2c).
- `down()` symétrique (drop des FK puis des colonnes).

### 2. Modèles
- `fillable` + relation `programmeBudget()` sur `DepotFacture` et `Facture`.
- Sur `ProgrammeBudget` : relations inverses `depotFactures()` et `factures()`.

### 3. Étendre `FactureMarcheDoubleAncrageService`
Ce service assure déjà la cohérence de `marche_id` entre dépôt et facture, avec un
`resolveMarcheIdEffectif` (facture prioritaire, sinon dépôt) consommé hors du dépôt.
Ajouter le pendant pour l'enveloppe, **selon exactement le même motif** :
- une méthode de résolution de l'enveloppe effective (facture prioritaire, sinon dépôt) ;
- l'alignement dépôt ↔ facture lors des écritures ;
- l'exclusivité dans `coerceProgrammesMarcheEtPosteDansValidated` : un `marche_id` et un
  `programme_budget_id` ne peuvent JAMAIS être renseignés simultanément. Si les deux
  arrivent, le marché gagne et l'enveloppe est mise à null (mais voir le point 4 : ce cas
  doit d'abord être REJETÉ en validation, la coercion n'est qu'un filet).

### 4. Contrôles STRICTS — le cœur du lot
Créer une méthode de contrôle unique, appelée depuis **TOUS** les points d'écriture d'un
dépôt, y compris ceux qui n'appellent aucun contrôle aujourd'hui (`valider`,
`validerHistorique`, `resolveEngagementPayloadPourValiderDejaValide`,
`resolveEngagementPayloadPourValiderEtComptabiliser`, override admin, autosave/brouillon,
`ComptabilisationController::update`). Fournir dans la synthèse le TABLEAU
« point d'écriture ↔ fichier:ligne ↔ contrôle appelé OUI/NON » **après** modification, pour
prouver qu'aucun flux n'est oublié.

Règles à faire respecter, **sans AUCUN early-return permissif** (rejet 422 explicite avec
message métier clair, jamais de sortie silencieuse) :
- `marche_id` et `programme_budget_id` simultanément renseignés → REJET.
- `programme_budget_id` renseigné : l'enveloppe doit exister, être active, non supprimée,
  et appartenir **au programme du dépôt** → sinon REJET.
- `programme_budget_id` renseigné : le poste de l'enveloppe doit avoir
  `applicable_budget = 1` → sinon REJET.
- `programme_budget_id` renseigné : **aucune retenue** (RG / caution / CP / OPC / CIE /
  finitions) ne peut être non nulle → REJET avec message explicite.
- `rattachement_poste_direct = true` (mécanique actuelle, encore en place) : le poste devient
  **OBLIGATOIRE** → REJET si absent. **C'est la correction du trou du #1022.**
- `sans_marche = true` : le poste devient **OBLIGATOIRE** → REJET si absent.

⚠️ **Compatibilité impérative avec les données existantes.** 5 dépôts en prod sont dans
l'état incohérent que ces règles interdisent désormais (`rattachement_poste_direct = true`
sans poste : depot#1091, #1164, #1165, #1168, #1183 — statuts `a_comptabiliser`, `paye`,
`comptabilisee`). Les nouveaux contrôles ne doivent PAS rendre ces dépôts inéditables ni
provoquer un 500 à leur ouverture : les contrôles s'appliquent **aux écritures**, et si l'un
de ces dépôts est réédité, le message d'erreur doit indiquer clairement à l'utilisateur qu'il
doit choisir un poste. Tester ce cas explicitement.

### 5. Comptabilisation
Dans `ComptabilisationController`, supprimer l'early-return permissif équivalent (≈L1113-1116)
et appliquer la règle 5 : si le rattachement (poste ou enveloppe) est présent mais que le
poste n'a pas de `compte_comptable_id` → **blocage** de la comptabilisation avec message
clair. Le repli `471000` reste réservé au cas `sans_marche` (« marché à créer »).
⚠️ 7 postes classés budget n'ont pas de compte comptable en prod : ce blocage est donc
volontairement visible. Ne pas l'adoucir.

### 6. Résolution du compte de charge
`ProgrammeChargeAccountResolver` ignore aujourd'hui les enveloppes. Ajouter la branche :
si `programme_budget_id` est présent → poste = celui de l'enveloppe → compte = compte du
poste. Conserver l'ordre de priorité existant pour les autres cas, sans le modifier.

## GARDE-FOUS
- **AUCUNE modification de l'interface utilisateur.** Ne PAS toucher
  `DepotFactureSaisieForm.vue`, ne PAS retirer les cases à cocher, ne PAS masquer les
  retenues côté front (c'est le sous-lot 2b). Si aucun `.vue` n'est modifié, AUCUN rebuild de
  bundle n'est attendu et `public/build/` ne doit PAS apparaître dans le commit.
- NE PAS backfiller de données, NE PAS créer d'enveloppe automatiquement (sous-lot 2c).
- NE PAS toucher : le bilan (`BilanFinancierService`), les marchés, les avenants, Sage/Intacct,
  le module tâches de suivi, le module Assistant/Aktor.
- NE PAS committer `PROJECT.md` (diff local non commité dans ton arbre depuis plusieurs lots),
  ni les untracked (`ERP*.zip`, `_tmp_*.php`, `_tmp_extract/`, `design_handoff_*/`), ni aucun
  fichier appartenant aux lots #1027 / #1028.

## TESTS À EXÉCUTER ET REPORTER
1. `migrate` + `migrate:status`, `down()` testé (rollback puis re-migrate).
2. **NON-RÉGRESSION, le test le plus important** : dépôt d'une facture de marché normale,
   saisie complète, validation, comptabilisation → comportement identique à avant le lot.
3. Dépôt avec « pas de marché, rattacher à un poste » + poste choisi → fonctionne comme avant.
4. Même cas SANS poste choisi → REJET 422 avec message clair (avant ce lot : accepté).
5. Écriture forcée avec `marche_id` ET `programme_budget_id` → REJET 422.
6. Écriture avec `programme_budget_id` d'une enveloppe d'un AUTRE programme → REJET 422.
7. Écriture avec `programme_budget_id` + une retenue non nulle → REJET 422.
8. Passage par `valider` puis `validerHistorique` sur un dépôt incohérent → contrôle bien
   appliqué (avant ce lot : aucun contrôle).
9. Ouverture en édition d'un des 5 dépôts incohérents existants (#1091, #1164, #1165, #1168,
   #1183) → pas de 500, message clair si tentative d'enregistrement.
10. Comptabilisation d'un dépôt sur un poste sans compte comptable → blocage avec message
    clair, pas de 500, pas de repli silencieux en 471000.

## QUALITÉ CODE — SORTIE BRUTE EXIGÉE
Pour chaque fichier PHP créé ou modifié : `php -l`, puis `ReflectionClass` + instanciation via
le container DI. **Coller les sorties brutes.** Un « OK » est refusé : les tests SQLite sont
skipped et ne détectent ni un `use` manquant ni une redéclaration → 500 en prod.
⚠️ Ce lot touche `DepotFactureController` et `ComptabilisationController` : un 500 ici bloque
toute la saisie de factures de l'entreprise. La preuve par Reflection est non négociable.

## LIVRABLE
- Diff complet + nom exact du fichier de migration.
- Le TABLEAU « point d'écriture ↔ fichier:ligne ↔ contrôle appelé » APRÈS modification.
- `git status --short` avant commit : uniquement les fichiers de ce lot.
- Confirmation explicite qu'aucun `.vue` n'est modifié et qu'aucun `public/build/` n'est
  dans le commit.
- Commit + push. Sortie brute de `git log --oneline -1 origin/main` (VRAI hash) et
  `git show --stat <hash>`.
- Créer `docs/suivi/SUIVI_1029_ancrage_budget_controles.md` dans le MÊME commit, selon la
  convention `docs/suivi/README.md` : frontmatter (`chantier: budgets-programme`,
  `type: fix`, `statut: poussé`, `hash:`, `fichiers:`), PROMPT ENVOYÉ = ce prompt intégral,
  SYNTHÈSE remplie, DÉPLOIEMENT / TEST vide.
- SYNTHÈSE dans UN bloc de code markdown copiable.

SUIVI #1029

## SYNTHÈSE

### Pré-requis git
- `origin/main` au démarrage : `befe0b09` puis resync après #1028 → `493550bb chore(suivi): renseigne hash #1028 (fd20b9c8)`
- Périmètre strict : aucun fichier Aktor (#1027) ni tâches (#1028) dans le commit.
- Aucun `.vue` modifié ; aucun `public/build/` dans le commit.

### Migration
- Fichier : `database/migrations/2026_07_28_210000_add_programme_budget_id_to_depot_factures_and_factures_table.php`
- Colonnes `programme_budget_id` nullable + FK `nullOnDelete` + index sur `depot_factures` et `factures`
- Pas de backfill
- `up` / `down` / `up` testés sur stubs locaux (MariaDB port 3307) : colonnes créées puis droppées puis recréées OK
- DB locale de travail incomplète (pas de `migrate:status` global fiable) — DDL validé isolément

### Modèles / double ancrage
- `DepotFacture` / `Facture` : fillable + `programmeBudget()`
- `ProgrammeBudget` : `depotFactures()` + `factures()`
- `FactureMarcheDoubleAncrageService` : `resolveProgrammeBudgetIdEffectif`, `resolveProgrammeBudgetIdDepotSaisie`, sync dans `appliquer` / `syncFactureLieeDepuisDepot` / `rattraper` / `estDivergent`
- Coerce : filet marché gagne sur enveloppe ; `sans_marche` ne vide plus le poste (socle « marché à créer » + poste)

### Contrôle unique
- `DepotFactureController::assertRattachementProgrammeBudgetValide` (public, L4244)
- Règles : exclusivité sans_marche↔rattachement ; rejet marche+enveloppe ; enveloppe active/non soft-deleted/bon programme/`applicable_budget` ; retenues interdites si enveloppe ; poste obligatoire si `rattachement_poste_direct` ; poste obligatoire si `sans_marche`
- ⚠️ Impact connu jusqu’au 2b : l’UI vide encore le poste dès `sans_marche=true` (watcher) → toute sauvegarde « marché à créer » sans poste renvoie 422. Ouverture inchangée (pas de 500). À traiter en 2b (select poste pour marché à créer).

### Tableau points d’écriture (APRÈS)

| Point d’écriture | Fichier:ligne | Contrôle `assertRattachementProgrammeBudgetValide` |
|---|---|---|
| `applyDepotSaisieFromValidated` | DepotFactureController.php:1373 | OUI (direct) |
| `update` (brouillon / autosave) | DepotFactureController.php:1671 (+ apply L1704) | OUI |
| `valider` | DepotFactureController.php:1827 | OUI (ajout #1029) |
| `resolveEngagementPayloadPourValiderDejaValide` | DepotFactureController.php:2266 | OUI (ajout #1029) |
| `resolveEngagementPayloadPourValiderEtComptabiliser` | DepotFactureController.php:2320 | OUI (ajout #1029) |
| `validerHistorique` | DepotFactureController.php:2408 | OUI (ajout #1029) |
| `applyDepotModificationEngageeDepuisRequete` (override admin / engagé) | via apply L3877 | OUI (indirect) |
| `ComptabilisationController::update` | via apply L282 | OUI (indirect) |

### Comptabilisation / resolver
- Early-return permissif `posteTypeId <= 0` supprimé
- Blocage si rattachement poste/enveloppe sans compte comptable ; `471000` réservé à `sans_marche`
- `ProgrammeChargeAccountResolver` : branche enveloppe en tête (facture → dépôt → saisie)

### Tests exécutés
1. Migration up/down/up isolé : OK
2. Assert marché normal : accepté (non-régression contrôle)
3. Rattachement + poste absent : 422 « Choisissez un poste… » (trou #1022 fermé)
4. marche_id + programme_budget_id : 422
5. enveloppe introuvable : 422
6. Réédition dépôt incohérent type #1091 : 422 clair à l’écriture (pas d’assert à l’ouverture)
7. `php -l` + ReflectionClass + `app()->make` sur tous les PHP touchés : OK (sortie brute dans livrable)
8. Tests manuels prod complets (valider/compta bout-en-bout) : à faire après déploiement OVH

### Journal
- Entrée `factures-rattachement-budget-controles-2026-07-28`

### Déploiement OVH
- `git pull origin main`
- `php artisan migrate --force`
- `php artisan optimize:clear` + `view:clear`
- `php artisan journal:sync`

## DÉPLOIEMENT / TEST


## LEÇON
L'exigence de poste obligatoire sur `sans_marche` était prématurée : le formulaire actuel efface le poste quand cette case est cochée. Corrigé au #1031 (exigence reportée au sous-lot 2b, logique A).
