---
suivi: 1631
date: 2026-09-22
sujet: FINANCIER > FACTURES d'un programme + factures en attente de marché
chantier: programmes / financier
type: feature
statut: poussé
hash: b3748918
fichiers:
  - app/Support/FactureMarcheARelier.php
  - app/Services/FactureGlobaleListeQuery.php
  - app/Http/Controllers/FactureGlobaleController.php
  - app/Http/Controllers/BilanFinancierController.php
  - app/Http/Controllers/ProgrammeBudgetController.php
  - app/Http/Controllers/ProgrammeTvaMargeController.php
  - resources/js/Pages/Programmes/Factures.vue
  - resources/js/Pages/Factures/Global.vue
  - resources/js/Components/Programmes/FinancierSubNav.vue
  - resources/js/Layouts/AppShell.vue
  - routes/web.php
  - tests/Unit/FinancierOngletFactures1631SourceTest.php
  - database/data/journal_mises_a_jour_post_2026_06_12.php
  - public/build/
  - docs/suivi/SUIVI_1631_financier_onglet_factures.md
---

# SUIVI #1631 — FINANCIER > FACTURES (visibilité des factures orphelines)

## PROMPT ENVOYÉ

Ajouter un 4ᵉ sous-onglet FACTURES sous FINANCIER : toutes les factures du
programme courant, présentées exactement comme /factures (colonnes, filtres,
tri, aperçu, actions), factures orphelines et en dépôt incluses. Ligne EN ROUGE
quand le saisisseur a choisi « Marché non encore créé — à relier ultérieurement »
au dépôt, avec libellé explicatif et moyen de ne voir que celles-là. Réutiliser
le composant et la requête existants, ne pas dupliquer, ne pas modifier
/factures ni les autres sous-onglets, ne pas implémenter l’imputation.

## FICHES LUES

- **#1617** — cite `FactureGlobaleController` + `FactureGlobaleListeQuery`
  (confirmé) et `ProgrammeFacturesPanel.vue`. Impact : le panneau programme
  existant est un autre besoin (voir constat), la liste globale est réutilisée.
- **#1611 (diagnostic 18/09)** — mesure des 113 factures : le critère A1
  s’ancre sur l’enveloppe **effective** `COALESCE(facture, dépôt)` + drapeau
  poste + **pas** `sans_marche`. C’est la preuve que `sans_marche` et
  `rattachement_poste_direct` sont deux états distincts (voir correction).
- **#1191** — remplacement des cases par les options sentinelles du select
  Marché ; tableau explicite `sans_marche=1` ↔ « Marché non encore créé ».
- **#1031** — correctif `sans_marche` + poste : même mapping.
- **#1622 / #1628 / #1630** — patron du sous-onglet FINANCIER : Teleport
  `#financier-subnav-host`, ancre URL, flag de visibilité **serveur**.
- **#1523 / #1029** — exclusivité marché / enveloppe ; ne rien imputer ici.

## AVANT MODIFICATION (constat)

### Liste globale /factures
| Élément | Valeur constatée |
|---|---|
| Route | `GET /factures` → `factures.globale` |
| Contrôleur | `FactureGlobaleController::index` (confirmé #1617) |
| Requête | `FactureGlobaleListeQuery::baseQuery` + `applyOrdering` (confirmé #1617) |
| Composant | `resources/js/Pages/Factures/Global.vue` (2 598 lignes) |
| Onglets | `tous`, `programmes`, `frais_generaux`, `avoirs`, `en_depot` (+ alias `autres`, `projet` → `tous` depuis #1599) |
| Filtres | référence, fournisseur, programme, marché, société, type, statut, référent, 3 plages de dates, montant TTC + opérateur, `inclure_en_depot`, `file_compta` |
| Tri | `FactureGlobaleListeQuery::SORTABLE` (12 clés) |
| Droits page | `FactureVisibility::userCanAccessFacturesPage` (permission `factures.lecture` **ou** au moins un dépôt visible) |
| Droits ligne | `FactureVisibility::applyScope` : socle total (GS_ADMIN_ERP / GS_DSI / GS_DIRECTION_GENERALE / GS_COMPTABILITE) voit tout ; sinon auteur du dépôt + validateur désigné NV1/NV2 |

### Sous-nav FINANCIER
`FinancierSubNav.vue` téléporte dans `#financier-subnav-host` (hôte AppShell
conditionné au nom du composant de page). Items poussés dans `items` selon un
booléen ; `HASH` + `tabFromHash` + `replaceHash` gèrent l’ancre, et
`syncFromLocation` renvoie sur `#bilan` si le hash cible un onglet non visible.

### 🔴 Où est stocké « Marché non encore créé — à relier ultérieurement »
**L’avertissement du prompt est inversé.** Preuve dans la source du dépôt
(`DepotFactureSaisieForm.vue` L1830-1907) :

| Choix du champ Marché | Sentinelle front | Écrit |
|---|---|---|
| Un marché existant | id numérique | `marche_id` |
| **« Marché non encore créé — à relier ultérieurement »** | `__sans_marche__` | **`sans_marche = true`** (colonne `depot_factures.sans_marche`, tinyint NOT NULL DEFAULT 0 — dump `hectareenvol.sql` L1359 ; doublée du JSON `ocr_raw.saisie.sans_marche`) |
| « Pas de marché, rattacher à un poste budgétaire… » | `__rattachement_poste__` | `rattachement_poste_direct = true`, **uniquement** dans le JSON `ocr_raw.saisie.rattachement_poste_direct` (aucune colonne) |

Corroboré par les fiches #1191 (« `sans_marche=1` | Marché non encore créé — à
relier ultérieurement ») et #1031, et par
`FactureMarcheDoubleAncrageService::resolveMarcheIdDepotSaisie` (L46-54) qui
annule le marché de saisie quand `sans_marche` est vrai.

Donc : `depot_factures.sans_marche` = **2ᵉ** choix, et le **3ᵉ** choix vit dans
`ocr_raw.saisie.rattachement_poste_direct`. C’est bien le second qui est en
attente ; le troisième est un rattachement assumé — conforme à la consigne
« ⚠️ ne pas mettre en rouge les factures simplement sans marché », mais avec la
clé inverse de celle annoncée.

**Combien en production ?** Non mesurable depuis l’environnement de
développement : MySQL local refuse la connexion (`SQLSTATE[HY000] [2002]`,
constat récurrent #1626 / #1628 / #1629 / #1630) et le dump est *structure
only*. Le chiffre n’est donc **pas** fourni ; requête de comptage livrée en
synthèse et ci-dessous. À noter : les 113 factures du 18/09 (#1611) ne sont
**pas** cette population — leur critère A1 exige `COALESCE(d.sans_marche,0) = 0`
**et** `rattachement_poste_direct` vrai, c’est-à-dire le 3ᵉ choix.

```sql
SELECT COUNT(*) AS nb, ROUND(SUM(COALESCE(f.montant_ht, d.montant_ht, 0)), 2) AS ht
FROM depot_factures d
LEFT JOIN factures f ON f.id = d.facture_id AND f.deleted_at IS NULL
WHERE d.deleted_at IS NULL
  AND (
    COALESCE(d.sans_marche, 0) = 1
    OR LOWER(TRIM(JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.sans_marche')))) IN ('1', 'true')
  )
  AND COALESCE(f.marche_id, d.marche_id) IS NULL;
```

### Panneau de factures existant sur le programme
`GET /programmes/{programme}/factures` (`factures.index`) →
`FactureController::index` → `Factures/Index.vue` → `ProgrammeFacturesPanel.vue`
(503 lignes), aussi monté par `SuiviChantier/Index.vue`. **Autre besoin** : KPI
programme + paiement fonds propres, colonnes propres, pas les filtres ni le tri
de /factures. Il est donc **conservé tel quel** et le nouvel onglet prend une
URL distincte (pas de collision).

## SYNTHÈSE

**Route** `GET /programmes/{programme}/financier/factures` →
`programmes.financier-factures.index`. URL distincte de l’ancien panneau
(`/programmes/{programme}/factures`), vérifiée par `route:list`.

**Zéro duplication.** `FactureGlobaleController::index` a été factorisé en
`filterRules()` / `construireFiltresActifs()` / `propsListe()` /
`resolveUserPourListe()` / `resolvePerPage()`, sans changement de comportement ;
la nouvelle méthode `programme()` réutilise **ces mêmes** briques et la **même**
`FactureGlobaleListeQuery`. Le front réutilise `Factures/Global.vue` importé par
une page fine `Programmes/Factures.vue` (aucun `<DataTable>` ni `<Column>`
recopié — assertion de test). Colonnes, filtres, tri et aperçu sont donc
identiques par construction.

**Périmètre programme.** Nouveau paramètre serveur `programme_contexte_id` dans
`baseQuery` → `DepotFacture::scopePourProgramme` (scope **existant** : dépôt du
programme, ou facture liée du programme, ou marché du programme) — les
orphelines rattachées au programme sans marché sont donc incluses. `tabCounts`
et `filterOptionsForTab` reçoivent un paramètre **optionnel** `programmeContexteId`
(défaut `null`) : compteurs et options de filtre reflètent le programme, et
`/factures` est inchangée. `inclure_en_depot` vaut `true` par défaut dans ce
contexte (les factures en dépôt du programme sont visibles d’emblée) et reste
pilotable par l’URL.

**Droits.** `resolveUserPourListe()` : `userCanAccessFacturesPage` (403 sinon) —
strictement la porte de /factures. La visibilité ligne à ligne reste
`FactureVisibility::applyScope`. **Aucun** contrôle de groupe ajouté (donc rien
à écrire en `m365GroupNames()` brut ici), **aucun** Gate, et volontairement
**pas** de `ProgrammeAccessService` ni de garde hors-ERP : ce serait *plus* que
les droits de la liste globale, où ces factures sont déjà visibles. Un compte
qui ne voit pas une facture dans /factures ne la voit pas ici (même scope).

**Ligne rouge.** `App\Support\FactureMarcheARelier` :
`estARelier()` = drapeau `sans_marche` (colonne **ou** JSON) **et** marché
effectif nul → une facture reliée après coup (rattrapage, SIWI) sort de l’état.
`applyScope()` produit le même prédicat en SQL, avec
`factures.deleted_at IS NULL` **explicite** dans la sous-requête (le
`COALESCE` SQL ne bénéficie pas du SoftDeletes Eloquent) ; le dépôt est filtré
par le scope Eloquent (`depot_factures.deleted_at is null` visible dans le SQL
compilé). Le flag `marche_a_relier` est sérialisé par ligne.

**Le rouge est expliqué** : la cellule Marché affiche un badge
« **Marché à relier** » avec infobulle « Au dépôt, le marché a été déclaré
“non encore créé — à relier ultérieurement”. Cette facture attend son
rattachement à un marché. », et un bandeau en tête annonce
« N facture(s) en attente de rattachement à un marché » avec un bouton
**« Ne voir que celles-ci »** (paramètre `f_marche_a_relier=1`) / « Voir toutes
les factures ». Fond `#FEF2F2`, texte `#991B1B`, via la classe de ligne
`facture-marche-a-relier`.

**/factures strictement inchangée** : `programme_contexte` vaut `null` hors
contexte programme, et `estMarcheARelier()` exige ce contexte → ni ligne rouge
ni badge sur la liste globale ; aucune colonne ajoutée ou retirée.

**Aucune imputation** (lot ultérieur) : rien n’écrit `programme_budget_id`,
`poste_budgetaire_type_id` ni `marche_id`.

## TABLEAU DE MAPPING UI ↔ BACK

| Affiché | Valeur postée / param | Colonne ou clé DB | Libellé back |
|---|---|---|---|
| Sous-onglet FACTURES | ancre `#factures`, URL `/programmes/{id}/financier/factures` | — | `programmes.financier-factures.index` |
| Visibilité du sous-onglet | prop `factures_onglet_visible` | — | `FactureVisibility::userCanAccessFacturesPage` |
| Badge « Marché à relier » | `marche_a_relier` (bool par ligne) | `depot_factures.sans_marche` **ou** `ocr_raw.saisie.sans_marche`, ET marché effectif NULL | `FactureMarcheARelier::estARelier` |
| Bouton « Ne voir que celles-ci » | `f_marche_a_relier=1` | même prédicat en SQL | `FactureMarcheARelier::applyScope` |
| Compteur du bandeau | `marche_a_relier.count` | — | `FactureGlobaleListeQuery::compteMarcheARelier` |
| Périmètre du programme | `programme_contexte_id` (serveur) | `depot_factures.programme_id` / `factures.programme_id` / `marches.programme_id` | `DepotFacture::scopePourProgramme` |
| Factures en dépôt | `inclure_en_depot` (défaut 1 ici) | `depot_factures.statut` | `applyTabScope` |

Non concerné par le rouge : `ocr_raw.saisie.rattachement_poste_direct` (3ᵉ
choix, rattachement assumé) — assertion de test explicite.

## VÉRIFICATIONS

`php -l` : OK sur les 7 fichiers PHP modifiés + le journal.

Reflection / DI : `FactureGlobaleController` instanciable via le conteneur,
méthodes `index` / `programme` / briques partagées présentes, paramètres
`programme(request, programme)` alignés sur le segment `{programme}` ;
`FactureGlobaleListeQuery` et `FactureMarcheARelier` résolus ; routes générées ;
matrice du drapeau vérifiée à l’exécution ; SQL du scope inspecté.

Tests : `FinancierOngletFactures1631SourceTest` (10 tests) + #1622 / #1628 /
#1630 / #1485 / #1507 / #1617 → **50 passed (483 assertions)**.
⚠️ Les tests Feature factures sont **skipped** (pdo_sqlite absent) : 38 skipped,
donc **aucune preuve DB** — d’où les assertions de source et l’inspection du SQL
compilé. Contrôle manuel en prod indispensable (tests ci-dessous).

## DÉPLOIEMENT-TEST

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

Pas de migration. Pas de `npm` / `composer` sur OVH.

1. FINANCIER : 4ᵉ sous-onglet FACTURES ; ancre `#factures` conservée au
   rechargement et au partage du lien.
2. La liste ne montre que les factures du programme courant.
3. Colonnes, filtres, tri, aperçu identiques à /factures.
4. Facture « marché à relier » : ligne rouge + badge « Marché à relier » +
   infobulle.
5. Facture rattachée à un poste budgétaire : **pas** en rouge.
6. Facture rattachée à un marché : **pas** en rouge.
7. Programme sans facture : liste vide propre (message « aucune facture »).
8. Compte aux droits restreints : même contenu que dans /factures.
9. ARBOREA, LES TRITONS, LOU MARQUES : orphelines visibles (onglet Tous, dépôts
   inclus par défaut).
10. BILAN, TVA SUR MARGE, BUDGET inchangés ; /factures inchangée.

## LEÇON

**Vérifier un mapping de saisie dans la source du formulaire, jamais dans
l’énoncé** : ici la consigne inversait les 2ᵉ et 3ᵉ choix du champ Marché.
`depot_factures.sans_marche` = « marché non encore créé », et le rattachement à
un poste vit **uniquement** dans `ocr_raw.saisie.rattachement_poste_direct`
(sans colonne). Se tromper aurait mis en rouge exactement la population qu’il
fallait laisser tranquille.

Réutiliser une page Inertia de 2 600 lignes sans la dupliquer : extraire les
briques du **contrôleur** (règles, filtres, props) et importer le composant de
page dans une page fine. La garantie « présentation identique » devient
structurelle, pas déclarative.

Un `COALESCE` SQL qui pointe une table en SoftDeletes doit porter son
`deleted_at IS NULL` à la main : le scope Eloquent ne descend pas dans une
sous-requête brute.
