---
suivi: 1026
date: 2026-07-28
sujet: Diagnostic cartographie rattachement facture avant logique A (poste → marché|enveloppe)
chantier: budgets-programme
type: diagnostic
statut: poussé
hash: 07997abb
fichiers:
  - docs/suivi/SUIVI_1026_diag_rattachement_logique_a.md
---

## PROMPT ENVOYÉ

SUIVI #1026

PHASE 1 — DIAGNOSTIC. NE PAS CODER. LECTURE SEULE.
Aucune modification dans app/, resources/, database/, routes/. Aucun commit hors le
fichier docs/suivi demandé en fin de prompt.

## CONTEXTE
ERP Hectarion (repo HECTAREG/erp-immo, prod envol.hectare.fr).
`origin/main` = `06fdc967`. AVANT DE COMMENCER : `git fetch origin && git checkout main &&
git pull origin main`, puis coller `git log --oneline -1 origin/main`.

Chantier BUDGETS PROGRAMME. Lots déjà en prod :
- **#1023** : flags `postes_budgetaires_types.applicable_marche` / `.applicable_budget`
  (l'ancien `applicable_charge_directe` a été RENOMMÉ en `applicable_budget`).
  Robin a classé les 42 postes ENVOL : 21 marché / 21 budget, exclusivité de fait.
- **#1025** : tables `programme_budgets` (N enveloppes par programme × poste : `libelle`,
  `montant_ht`, `actif`, softdeletes) + `programme_budget_revisions` (audit append-only),
  `ProgrammeBudgetService`, onglet BUDGET du programme, droits DG + Admin ERP.

Lot 2 À VENIR (à préparer, PAS à coder ici) : refonte du rattachement d'une facture
programme selon la **LOGIQUE A** décidée par Robin —
1. l'utilisateur choisit d'abord le **POSTE budgétaire** ;
2. l'ERP en déduit la suite : poste `applicable_marche` → sélecteur de MARCHÉS du programme ;
   poste `applicable_budget` → sélecteur d'ENVELOPPES du programme ;
3. le sélecteur de marchés comporte une option explicite en tête
   « Marché pas encore créé — à relier plus tard » ;
4. **les 2 cases à cocher actuelles DISPARAISSENT** (« Marché non encore créé — à relier
   ultérieurement » = `sans_marche`, et « Pas de marché, rattacher à un poste budgétaire »
   = `rattachement_poste_direct`), ainsi que leurs watchers d'exclusion mutuelle.

Objectif structurel : un seul sélecteur, un seul état, aucune combinaison incohérente
possible. Le diag #1022 a établi que le back autorise aujourd'hui
`rattachement_poste_direct = true` avec un poste NULL (early-return permissif) → 5 dépôts
incohérents en prod sur 23 usages (22 %).

## POURQUOI CE DIAG
`resources/js/Pages/Factures/DepotFactureSaisieForm.vue` fait ~6800 lignes et c'est le
fichier par lequel passent TOUTES les factures de l'entreprise. Une régression y bloque la
saisie quotidienne de la comptabilité. Je veux la cartographie exacte AVANT de toucher quoi
que ce soit. Aucune supposition : chaque affirmation doit porter un fichier:ligne.

(Note : le fichier réel est `resources/js/Pages/DepotFactures/DepotFactureSaisieForm.vue`, ~9101 lignes.)

## PARTIE A / B / C — voir SYNTHÈSE ci-dessous.

## SYNTHÈSE

### Pré-requis git
- `git log --oneline -1 origin/main` : `06fdc967 chore(suivi): renseigne hash #1025 (d8adcf2d)`
- `git status --short` : `M PROJECT.md`, `M vendor/bin/.phpunit.result.cache`, untracked ERP*.zip / _tmp_* / design_handoff_* / .cursor/rules/livrable-format.mdc — aucun fichier applicatif modifié par ce lot.

Fichier cartographié : `resources/js/Pages/DepotFactures/DepotFactureSaisieForm.vue` (pas `Factures/…`).

---

## PARTIE A — FRONT

### A1 — Inventaire bloc rattachement (template)

| Zone | Plage | Affichage | Désactivation |
|------|-------|-----------|---------------|
| Destination PROGRAMMES / FRAIS GÉNÉRAUX | `DepotFactureSaisieForm.vue:6674-6717` | Toujours dans onglet infos ; boutons si `!programmeSaisieVerrouillee` (L6677), sinon label figé PROGRAMMES (L6711) | fieldset `:disabled="readonly"` (L6640) |
| Programme | L6726-6745 | `v-if="form.destination === 'PROGRAMMES'"` dans `afficherEtape2C` (L6720) | Select si non verrouillé ; sinon InputText readonly (L6738) |
| Marché | L6759-6916 | dans `afficherEtape3C` (L6760) | Select `:disabled="readonly \|\| horsMarcheProgrammes"` (L6786) |
| Case `sans_marche` | L6847-6857 | `v-if="showSansMarcheCheckbox"` | `:disabled="readonly"` |
| Case `rattachement_poste_direct` | L6858-6868 | `v-if="showRattachementPosteCheckbox"` | `:disabled="readonly"` |
| Select `poste_budgetaire_type_id` | L6869-6885 | `v-if="form.rattachement_poste_direct && form.destination === 'PROGRAMMES'"` | `:disabled="readonly"`, `show-clear` |

Computed d'affichage :
- `showSansMarcheCheckbox` L206-210 : `(props.marche_id == null \|\| vide) && !marcheIdPresentDansQueryOuPrefill`
- `horsMarcheProgrammes` L213-215 : `form.sans_marche \|\| form.rattachement_poste_direct`
- `showRattachementPosteCheckbox` L234-236 : `showSansMarcheCheckbox && form.destination === 'PROGRAMMES'`
- Étapes `afficherEtape2/3/4` + variantes C : L2328-2354

### A2 — Watchers / computed (exhaustif rattachement)

| Ligne | Déclencheur | Effets de bord |
|-------|-------------|----------------|
| L551 | `programmeVerrouId` | force destination=PROGRAMMES + programme_id |
| L2631 | `form.programme_id` | suggestion compte bancaire (pas de reset rattachement) |
| **L3687** | `form.programme_id` | **reset** marche_id, sans_marche, rattachement_poste_direct, poste_budgetaire_type_id, multi_fournisseur, ventilations, sous-traitants, pénalités, fournisseur |
| **L3715** | `form.sans_marche` | si true : rattachement=false, poste=null, marche_id=null, multi/ventilations/pénalités vidés |
| **L3734** | `form.rattachement_poste_direct` | si true : sans_marche=false, marche_id=null, multi/ventilations/pénalités ; si false : poste=null |
| **L3754** | `form.marche_id` | si id + horsMarche → force hors marché off ; reset multi/ventilations/pénalités ; si vidé → reset fournisseur + showRG/CP/CIE=false |
| L3811 | `props.marches` | resync fournisseur si marche déjà posé |
| **L3847** | `[programme_id, marche_id, destination, sans_marche, rattachement_poste_direct]` | `loadPenalitesMarcheDisponibles()` |
| **L3979** | `form.destination` | si change : reset programme/marche/sans_marche/rattachement/poste/multi/ventilations/catégorie/fournisseur ; si FRAIS_GENERAUX reset retenues UI |

Computed liés (affichage, sans reset) : `postesChargeDirecteOptions` L217, `afficherEtape*` L2328, `afficherBoutonMultiFournisseur` L1896 (`!horsMarcheProgrammes`), `marchesFiltres` L1504, `marcheSelectionne` L1612.

### A3 — Champs payload rattachement

| Champ | buildDraftPayload L5281-5370 | buildValiderPayload L5842-5881 |
|-------|------------------------------|--------------------------------|
| destination | data.destination L5295 | via `...data` L5854 |
| programme_id | L5296 | L5865 explicite |
| marche_id | null si sans_marche \|\| rattachement ; sinon Number (L5299) | `data.marche_id \|\| null` **sans** coerce exclusivité (L5866) |
| poste_budgetaire_type_id | seulement si rattachement (L5300) | `data.poste_budgetaire_type_id \|\| null` (L5867) |
| sans_marche | coerce : marché gagne ; sinon rattachement prime sur sans_marche (L5288-5293, L5301) | **non listé** → spread `...data` |
| rattachement_poste_direct | idem (L5288-5291, L5302) | **non listé** → spread `...data` |
| multi_fournisseur / ventilations | forcés false/[] si hors marché (L5357-5367) | multi bool + ventilations mappées (L5876-5877) |

Écart critique : draft coerce l'exclusivité ; valider s'appuie sur le back (`coerceProgrammesMarcheEtPosteDansValidated`).

### A4 — Masquage retenues / multi / ventilations / pénalités / ST

| Élément | Condition exacte | Ligne |
|---------|------------------|-------|
| Conteneur RG/CP/Finitions/OPC/Pénalités | `v-if="form.destination === 'PROGRAMMES'"` — **PAS** de garde `horsMarcheProgrammes` | L7702 |
| Checkboxes RG/CP/Finitions/OPC | `:disabled="readonly"` seulement — **cliquables en mode hors marché / poste direct** | L7705-7718 |
| Pénalités checkbox | `:disabled="readonly \|\| !form.marche_id \|\| horsMarcheProgrammes"` | L7725 |
| Bloc détail pénalités | `showPenalites && form.marche_id && !horsMarcheProgrammes` | L7841 |
| Bouton multi-fournisseur | `afficherBoutonMultiFournisseur` = PROGRAMMES + marche_id + !horsMarche + sous-traitants + !readonly | L1896-1904, UI L7202 |
| Ventilations | `v-if="form.multi_fournisseur"` ; multi remis à false par watchers hors marché | L7420 + A2 |
| Sous-traitance | dépend `marcheSelectionne` → besoin marche_id | L1884+ |
| CIE (association) | programme requis ; pas de disable explicite hors marché sur le bloc cagnotte L7923+ | — |
| Reset flags retenues | marche vidé → showRG/CP/CIE=false L3789-3791 ; destination FRAIS → L4008-4021 | |

⚠️ Règle métier arrêtée (mode BUDGET = aucune retenue) : **non appliquée aujourd'hui** — RG/CP/Finitions/OPC restent visibles/activables si `destination===PROGRAMMES` même avec `rattachement_poste_direct` ou `sans_marche`.

### A5 — Filet 471000

- Constante : `COMPTE_CHARGE_PROGRAMME_SANS_MARCHE = '471000'` L4178
- Application : `numeroCompteChargeSelonDestination` L4446-4455 : PROGRAMMES + pas d'accountno résolu + (`sans_marche` OU (`rattachement_poste_direct` && !accountno)) → 471000
- `resolvePosteChargeProgramme` L4409+ : poste via `poste_budgetaire_type_id` si rattachement, sinon poste du marché
- En logique A : le filet reste pertinent pour « Marché pas encore créé » (ex-sans_marche) ; pour enveloppe Budget, le compte doit venir du poste (ou bloquer si absent — 7 postes budget sans compte en prod). Ne plus proposer 471000 comme filet silencieux d'un rattachement poste sans compte.

### A6 — Parents qui montent le formulaire

| Parent | Montage | postes_charge_directe_par_programme | marche_id |
|--------|---------|-------------------------------------|-----------|
| `DepotFactures/Index.vue` | L1391+ | oui via saisiePayload L1412 | non (défaut null) |
| `DepotFactures/Show.vue` | L453+ | props L31 + v-bind | non déclaré → null |
| `Factures/Comptabilisation/Index.vue` | L698+ via comptaFormProps L328-372 | L353 | non dans props → null |
| `Marches/Show.vue` | L4660-4688 | **ABSENT** → `{}` | **`marche_id: props.marche.id` en dur L4682** → masque cases hors marché |

Parents confirmés = 4 (#1019). Aucun 5e trouvé.

---

## PARTIE B — BACK

### B1 — Points d'entrée écriture dépôt

| Méthode | Fichier:ligne | Exclusifs | coerce | assertRattachementPoste |
|---------|---------------|-----------|--------|-------------------------|
| `applyDepotSaisieFromValidated` | DepotFactureController.php:1353 | L1361 | L1363 | **OUI L1362** |
| `update` | :1631 | L1658 | via apply | **OUI L1659** (+ apply) |
| `applyDepotModificationEngageeDepuisRequete` | :3811 | via apply :3865 | via apply | **OUI indirect** |
| `validerWorkflowEngage` | :1706 | via apply | via apply | **OUI indirect** |
| `valider` | :1777 | L1815 | L1817 | **NON** |
| `resolveEngagementPayloadPourValiderDejaValide` | :2239 | L2254 | L2265 | **NON** |
| `resolveEngagementPayloadPourValiderEtComptabiliser` | :2293 | L2308 | L2310 | **NON** |
| `validerHistorique` | :2353 | L2396 | L2398 | **NON** |
| `ComptabilisationController::update` | ComptabilisationController.php:212 | via apply :282 | via apply | **OUI indirect** |
| `DepotFactureValiderDejaValideService` / `ValiderEtComptabiliserService` | services | — | — | **NON** (consomment engagement) |
| `store` / `storeFromOutlook` | :163 / :217 | N/A (upload) | — | — |

Confirmé #1022 : `depotFactureValiderValidationRules` (:865-926) **n'inclut pas** sans_marche / rattachement_poste_direct / poste_budgetaire_type_id.

`assertRattachementPosteChargeDirecteValide` :4248-4283 — **early-return si posteTypeId <= 0** (L4258-4260) → laisse passer rattachement sans poste.

### B2 — FactureMarcheDoubleAncrageService

Fichier : `app/Services/FactureMarcheDoubleAncrageService.php`.
Rôle (L10-13) : aligner `factures.marche_id`, `depot_factures.marche_id`, `sans_marche`, `ocr_raw.saisie.marche_id` (#156/#168).

Méthodes clés : `resolveMarcheIdEffectif` L23 (facture prioritaire), `coerceSansMarcheMarcheDansValidated` L56, `coerceProgrammesMarcheEtPosteDansValidated` L82 (exclusivité marché ↔ sans_marche ↔ rattachement + clear poste), `appliquer` L134, `syncFactureLieeDepuisDepot` L217.

`resolveMarcheIdEffectif` **est largement utilisé** : RetenueGarantieService, CompteProrataService, CompteProrataSortieService, RetenueHistoriqueCollecteService, ComptabilisationIdentiteGuard, FactureGlobaleMarchePresenter, MarcheDeplacerFactureService — pas seulement interne.

Extension enveloppe : **oui, point d'extension naturel** (4e axe `programme_budget_id` dans coerce/appliquer/sync), même pattern que marche.

### B3 — ProgrammeChargeAccountResolver

`app/Support/ProgrammeChargeAccountResolver.php` L18-39 :
1. Si !sans_marche && !rattachement → poste via marché (`posteDepuisMarche`, marche_id dépôt > saisie > facture L89+)
2. Sinon / si échec → `posteDepuisIdsExplicit` : depot.poste_budgetaire_type_id > saisie.poste_budgetaire_type_id
3. Compte = poste.compteComptable.accountno
Aucune connaissance de `programme_budgets`.

### B4 — Lectures `sans_marche` (app/)

| Occurrence | Rôle |
|------------|------|
| DepotFactureController :835,935-937,1365,1485,1539,1819+,2883,3135,3357,3512,4231 | validation, écriture colonne+JSON, liste prefill marché (`where sans_marche false`), payload front, assert exclusifs |
| ComptabilisationController :1101,2564 | court-circuit assert compte / filtre APBILL |
| FactureMarcheDoubleAncrageService (multiple) | coerce, resolve saisie, sync, divergences |
| ProgrammeChargeAccountResolver :20 | désactive résolution marché |
| AlerteService :628 | désactive alerte marché |
| DepotFactureValider*Service | transport engagement |
| AlignSansMarcheDivergentsCommand | rattrapage batch #389 |
| ComptabilisationIdentiteGuard :105 | skip vérif marché |
| MarcheRapprocherFactureService :50,225,232 | candidats + écriture rapprochement |
| FactureHistoriqueSaisie :29 | snapshot historique |
| DepotFacture model :144,845 | fillable + cast |

→ Colonne **lue** hors saisie (liste, alertes, rapprochement, commande alignement). Abandon = migration de données + remplacement sémantique (« marché à créer » = marche_id null + flag ou option sentinel).

### B5 — Lectures `rattachement_poste_direct`

Uniquement JSON `ocr_raw.saisie` (pas de colonne) : DepotFactureController validation/écriture/payload/assert ; coerce FactureMarcheDoubleAncrageService ; ComptabilisationController :1105 ; ProgrammeChargeAccountResolver :21.

### B6 — Ancrage enveloppe — **RECOMMANDATION**

Manque aujourd'hui : `programme_budget_id` nulle part ; `ProgrammeBudgetService::estConsomme` retourne false (hook lot 2, L150-156).

| Colonne | factures | depot_factures |
|---------|----------|----------------|
| marche_id | oui | oui (double ancrage) |
| poste_budgetaire_type_id | **non** | oui |
| sans_marche | **non** | oui |
| rattachement_poste_direct | non | JSON only |
| programme_budget_id | **non** | **non** |

**Recommandation (étiquetée) : DOUBLE ANCRAGE — `programme_budget_id` nullable sur `depot_factures` ET `factures`.**

Justification code (pas préférence) :
1. Le bug d'affichage / divergence marché a été traité par double ancrage explicite (`FactureMarcheDoubleAncrageService` L10-13) ; `resolveMarcheIdEffectif` lit **facture d'abord** et est consommé par RG, CP, liste globale, déplacer — tout chemin qui ignore le dépôt.
2. Une enveloppe est un ancrage de **consommation** du même rang qu'un marché (pas un simple attribut de type poste). `poste_budgetaire_type_id` asymétrique (dépôt seul) suffit car dérivable du marché **ou** de l'enveloppe ; `programme_budget_id` ne l'est pas.
3. Lot 3 bilan / « Budget lié » fiche facture devront afficher l'enveloppe depuis la facture engagée ; dépôt-seul force des jointures systématiques et reproduit le risque de divergence dépôt↔facture au « Modifier ».
4. Étendre `coerce`/`appliquer`/`syncFactureLieeDepuisDepot` avec `programme_budget_id` (exclusif avec marche_id effectif ; compatible avec poste type dérivé de l'enveloppe).

Alternative rejetée pour le lot 2 : dépôt-seul « comme poste_budgetaire_type_id » — plus simple à migrer, mais contredit le pattern qui a corrigé les divergences marché et fragilise l'affichage post-engagement.

---

## PARTIE C — RISQUES ET DÉCOUPAGE

### C1 — Régressions possibles (gravité)

| Gravité | Risque | Test manuel |
|---------|--------|-------------|
| Critique | Régression saisie quotidienne (watchers reset en cascade) | Créer dépôt PROGRAMMES → programme → poste marché → marché → valider ; puis poste budget → enveloppe → valider |
| Critique | Valider sans assert poste/enveloppe (trou #1022) | Forcer POST valider avec poste null / enveloppe absente → doit 422 |
| Haute | Retenues encore saisissables en mode Budget (A4) | Mode enveloppe : cases RG/CP/Finitions/OPC absentes ou inertes ; payload sans retenues |
| Haute | Divergence facture↔dépôt si ancrage enveloppe incomplet | Valider puis Modifier enveloppe ; liste factures + fiche marché/budget cohérents |
| Haute | Marches/Show sans postes_charge / enveloppes | Déposer depuis fiche marché : cases hors marché masquées OK ; ne pas casser le prefill marche_id |
| Moyenne | Filet 471000 mal appliqué (budget sans compte) | Poste 36 sans compte : warning ou blocage compta, pas 471000 silencieux si enveloppe choisie |
| Moyenne | Multi-fournisseur / pénalités / CIE réactivés à tort | Vérifier absents dès qu'enveloppe ou « marché à créer » |
| Moyenne | Parents Index/Show/Compta props manquantes | Ouvrir saisie depuis les 4 parents ; sélecteurs postes/enveloppes peuplés |
| Basse | Commande AlignSansMarche / alertes / rapprochement | Après migration données, scripts et alertes ne plantent pas |

### C2 — Découpage lot 2 suggéré (séquentiel, ERP utilisable)

| Sous-lot | Périmètre | Fichiers | Entre-deux |
|----------|-----------|----------|------------|
| 2a Fondations données | migration `programme_budget_id` depot+facture ; étendre DoubleAncrage coerce/sync ; assert strict (poste obligatoire + enveloppe si budget) ; brancher `estConsomme` | migrations, models, FactureMarcheDoubleAncrageService, DepotFactureController asserts, ProgrammeBudgetService | UI inchangée ; back refuse incohérences nouvelles écritures |
| 2b Front logique A | refonte bloc A1 : select poste → marché\|enveloppe ; option « marché à créer » ; suppression 2 checkboxes + watchers L3715/L3734 ; masquer retenues si budget ; props parents + Marches/Show | DepotFactureSaisieForm.vue, 4 parents, payloads A3 | Nouveau UX ; anciennes données encore lisibles via mapping |
| 2c Migration données | convertir populations C3 ; backfill programme_budget_id / flags | commande artisan ou migration data | Après 2a+2b |
| 2d Nettoyage | déprécier lectures rattachement_poste_direct ; documenter sans_marche (garder colonne jusqu'à extinction « marché à créer » ou la renommer sémantiquement) | grep B4/B5 | Optionnel, lot séparé |

### C3 — Compatibilité données existantes

| Population | Devenir logique A | Migration ? |
|------------|-------------------|-------------|
| 49 `sans_marche=1` | État « Marché pas encore créé » : marche_id null, poste éventuellement à saisir plus tard ou conservé si connu ; option sentinel marché | Oui : mapper vers nouvel état (poste nullable ou poste marché requis ?) — **arbitrage Robin C4** |
| 18 rattachement poste réel | Choisir une enveloppe du (programme, poste) ou créer enveloppe « reprise » si N=0 | Oui : backfill `programme_budget_id` (règle si plusieurs enveloppes) |
| 5 incohérents (rattachement + poste NULL) | Corriger avant/pendant migration : forcer choix poste+enveloppe ou abandonner rattachement | Oui + nettoyage manuel |
| 12 surcharge poste sur facture de marché | Rester mode marché (poste vient du marché) ; ignorer surcharge UI ou la retirer | Probablement non (déjà marché) — vérifier cas par cas |

### C4 — Questions ouvertes (arbitrage Robin)

1. « Marché pas encore créé » : le poste budgétaire est-il **obligatoire** dès la saisie, ou reportable (comme aujourd'hui sans_marche sans poste) ?
2. Si plusieurs enveloppes sur le même poste : laquelle pour backfill des 18 ? (plus récente active / demander manuel / enveloppe technique « Non ventilé »)
3. Double ancrage `programme_budget_id` sur factures : confirmer recommandation B6.
4. Mode Budget : bloquer **strictement** toute retenue (y compris montants déjà OCR) ou seulement masquer l'UI ?
5. Poste sans `compte_comptable_id` : bloquer validation, bloquer seulement comptabilisation, ou garder filet 471000 ?
6. Conserver la colonne `sans_marche` (renommer sémantique) ou la remplacer par `marche_id` null + enum `mode_rattachement` ?
7. `Marches/Show` : faut-il permettre bascule vers enveloppe depuis une fiche marché (aujourd'hui marche_id figé) ?

### LEÇON
Un fichier de saisie à 9000 lignes + watchers d'exclusion mutuelle + asserts permissifs (`if id<=0 return`) produit des états impossibles à reconstruire mentalement — cartographier avant de toucher.

## DÉPLOIEMENT / TEST

