---
suivi: 1644
date: 2026-09-22
sujet: Dépenses hors facture — fix sélecteur fournisseur, saisie tableur, pièces en colonne, suppression douce
chantier: Financier / Bilan
type: fix+feature
statut: poussé
hash: 5af45bfa
fichiers:
  - resources/js/Components/Commercialisation/FournisseurSearchSelect.vue
  - resources/js/Pages/DepensesManuelles/Index.vue
  - tests/Unit/DepensesHorsFactureTableur1644SourceTest.php
  - database/data/journal_mises_a_jour_post_2026_06_12.php
  - public/build/
  - docs/suivi/SUIVI_1644_depenses_hors_facture_tableur.md
---

# SUIVI #1644 — dépenses hors facture : saisie tableur et fix fournisseur

## PROMPT ENVOYÉ

Corriger le bug bloquant du champ Fournisseur (la proposition cliquée ne se met pas dans le
champ, `fournisseur_id` reste vide, aucune dépense enregistrable), refondre l'écran en saisie
tableur (ligne vide en bas, Entrée valide, Tab à droite, Échap annule, lignes existantes
éditables sur place, densité 27-28 px), passer le bandeau jaune dans un « ? », mettre les pièces
jointes en colonne (trombone + compteur, modale, désactivé sur la ligne non enregistrée) et
ajouter une suppression douce avec confirmation (option A : pas d'affichage des supprimées, pas
de journal à l'écran). Prouver que `totauxHtParPosteBudgetaire()` exclut `deleted_at`.

Complément demandé en cours de lot : prouver pour CHACUN des 3 écrans consommateurs du composant
que le retrait de `force-selection` n'ouvre pas de trou (validation serveur `exists` sur
`fournisseurs`), et s'arrêter si l'un d'eux n'en a pas.

## FICHES LUES

- **#1638** — le lot d'origine de l'écran : arbitrages comptables (Facturé Payé et **pas**
  « Facturé »), TTC dérivé, droits m365 bruts, profil documentaire
  `programme-depenses-manuelles`, `whereNull('d.deleted_at')` déjà posé. **Rien de tout cela
  n'a été modifié** : le lot est purement front, plus un test.
- **#1635 / #1636** — grammaire tableur (`registerCell` / `focusCellule` / `memoriserValeur` /
  `annulerEdition` / `onCelluleEnter`). **Réutilisée à l'identique**, pas réécrite : c'est ce qui
  garantit qu'Entrée, Tab et Échap se comportent pareil ici et en faisabilité.
- **#1156** — lot d'origine de `FournisseurSearchSelect`. Le composant n'avait jamais été
  retouché depuis (`git log` : un seul commit, `69ae5378`).

## PARTIE 1 — LE BUG FOURNISSEUR

### Branchement AVANT (écran) — cité en brut

```vue
<FournisseurSearchSelect
    v-model="form.fournisseur_id"
    :initial-label="fournisseurLabel"
    :search-url="fournisseurs_search_url"
/>
```

### Contrat RÉEL du composant — cité en brut

```js
const model = defineModel({ type: [Number, String, null], default: null });
// …
function onItemSelect(event) {
    const item = event?.value;
    if (item?.id != null) {
        model.value = Number(item.id);
        selected.value = item;
    }
}

function onClear() {
    model.value = null;
    selected.value = null;
    suggestions.value = [];   // ← le coupable
}
```

```vue
<AutoComplete
    v-model="selected"
    :suggestions="suggestions"
    option-label="label"
    force-selection          <!-- ← le déclencheur -->
    dropdown
    show-clear
    @complete="complete"
    @item-select="onItemSelect"
    @clear="onClear"
/>
```

### Autres consommateurs et leur branchement

| Écran | Champs | Branchement |
|---|---|---|
| `Pages/Programmes/Acquereur/Show.vue` | notaire, notaire DM, agence | `v-model="form.notaire_fournisseur_id"` + `:initial-label` |
| `Components/Commercialisation/LotsGrillePanel.vue` (modale de réservation) | agence, notaire, notaire DM | `v-model="reserverForm.agence_fournisseur_id"` (+ `:initial-label` pour l'agence) |
| `Pages/DepensesManuelles/Index.vue` | fournisseur | `v-model="form.fournisseur_id"` + `:initial-label` + `:search-url` |

**Branchement strictement identique aux trois endroits.** L'écart n'était donc PAS dans le
branchement : le défaut est dans le composant, et il frappe ses trois consommateurs.

### L'écart exact — CAUSE PROUVÉE, pas déduite

Reproduction dans un navigateur réel, sur un banc Vite montant le composant réel avec un faux
`/fournisseurs/search` renvoyant la charge utile exacte de `FournisseurController::search`.
Séquence d'événements instrumentée, sortie brute :

    input  value="madn"
    change value="madn"
    mousedown on LI.p-autocomplete-option  inputValue=""
    mouseup   on LI.p-autocomplete-option  inputValue=""
    click     on LI.p-autocomplete-option  inputValue=""

Chaîne causale, vérifiée dans `node_modules/primevue/autocomplete/AutoComplete.vue` (4.5.5) :

1. `force-selection` fait exécuter `onChange()` sur l'événement **natif `change`** de l'input
   (ligne 32 : `@change="onChange"`).
2. `onChange()` (L477-503) compare le texte de l'input au libellé EXACT d'une option
   (`isOptionMatched`, L899). « madn » ≠ « MADN — MADN — SAINT MARTIN DE LONDRES ».
3. Aucune correspondance → `inputEl.value = ''`, **`this.$emit('clear')`** et
   **`this.updateModel(event, null)`** (L496-500).
4. Or `change` part au **blur provoqué par le mousedown sur la proposition**, donc **AVANT**
   `item-select` (qui, lui, part sur `click`).
5. `onClear()` du composant faisait `suggestions.value = []` : le `<li>` sous le curseur est
   retiré du DOM entre `mousedown` et `mouseup`, donc **aucun `click` n'est dispatché** et
   `onItemSelect` n'est jamais appelé. Le champ reste vide, `fournisseur_id` reste `null`.

Le composant est en cause, pas son branchement. La preuve directe du point 3 : en envoyant un
simple `change` alors que la liste est ouverte, `visibleOptions` passe de 1 à 0 — ce qui ne peut
venir que de `onClear()`.

### Correction

- `force-selection` **retiré** du composant.
- `onClear()` ne vide plus `suggestions` (il n'a aucune raison de détruire la liste ouverte).
- Ajout d'un `onBlur` qui remet l'affichage en cohérence avec la sélection réelle : du texte
  tapé sans choisir de proposition ne laisse aucune trace trompeuse.

Après correction, clic souris réel sur la proposition : le champ affiche
« MADN — MADN — SAINT MARTIN DE LONDRES » et `fournisseur_id = 4242`, et la sélection **survit**
à un blur/`change` suivant.

### 🔴 Le retrait de `force-selection` n'ouvre AUCUN trou — vérifié pour les 3 écrans

`force-selection` était une garantie d'**interface**, jamais de sécurité : `model` n'est écrit
que depuis `item-select`, donc jamais depuis du texte libre. Et les trois endpoints valident
l'identifiant **côté serveur**. Cités en brut :

**1. Dépenses hors facture** — `ProgrammeDepenseManuelleController::valider()`

```php
'fournisseur_id' => ['required', 'integer', Rule::exists('fournisseurs', 'id')],
```

**2. Fiche acquéreur** (`PUT programmes/{programme}/reservations/{reservation}/acquereur`)
— `App\Http\Requests\UpdateAcquereurFicheRequest::rules()`

```php
'agence_fournisseur_id' => [
    'nullable',
    'integer',
    'exists:fournisseurs,id',
    FournisseurActifValidation::pourSelection(
        $reservation?->agence_fournisseur_id ? (int) $reservation->agence_fournisseur_id : null,
    ),
],
'notaire_fournisseur_id' => [
    'nullable',
    'integer',
    'exists:fournisseurs,id',
    FournisseurActifValidation::pourSelection(
        $reservation?->notaire_fournisseur_id ? (int) $reservation->notaire_fournisseur_id : null,
    ),
],
'notaire_double_minute_fournisseur_id' => [
    'nullable',
    'integer',
    'exists:fournisseurs,id',
    FournisseurActifValidation::pourSelection(
        $reservation?->notaire_double_minute_fournisseur_id
            ? (int) $reservation->notaire_double_minute_fournisseur_id
            : null,
    ),
],
```

**3. Modale de réservation** (`POST programmes/{programme}/lots-commerciaux/{lot}/reserver`)
— `ReservationController::store()`

```php
'reservation.agence_fournisseur_id' => [
    'nullable',
    'integer',
    'exists:fournisseurs,id',
    FournisseurActifValidation::pourSelection(),
],
'reservation.notaire_fournisseur_id' => [
    'nullable',
    'integer',
    'exists:fournisseurs,id',
    FournisseurActifValidation::pourSelection(),
],
'reservation.notaire_double_minute_fournisseur_id' => [
    'nullable',
    'integer',
    'exists:fournisseurs,id',
    FournisseurActifValidation::pourSelection(),
],
```

Et `ReservationController::validateReservationUpdate()` (méthode d'`update`) reprend les trois
mêmes règles avec la tolérance `pourSelection($reservation?->…)`.

`FournisseurActifPourSelection` va plus loin qu'un `exists` : il exige `statut = 'actif'`, en
tolérant seulement la valeur **déjà enregistrée** sur la ligne. Une valeur hors référentiel est
donc refusée en 422 sur les trois écrans, `force-selection` ou pas. Verrouillé par le test
`les_trois_ecrans_valident_le_fournisseur_cote_serveur`.

Les deux autres consommateurs sont par ailleurs **réparés** par le même correctif : ils
souffraient exactement du même défaut (composant unique, branchement identique).

## PARTIE 2 — SAISIE TABLEUR

La carte « Nouvelle dépense hors facture » a disparu. La saisie se fait dans la dernière ligne du
tableau, et les lignes enregistrées s'éditent sur place (PUT à la sortie de cellule ou sur
Entrée, uniquement si la valeur a changé — comparaison à un `snapshot` par ligne).

### Tableau de mapping des colonnes

| En-tête affiché | Valeur postée | Colonne de `programme_depenses_manuelles` | Saisi / calculé |
|---|---|---|---|
| Date paiement | `date_paiement` (`YYYY-MM-DD`) | `date_paiement` | saisi |
| Fournisseur | `fournisseur_id` (int) | `fournisseur_id` | saisi (référentiel) |
| Poste (ligne de bilan) | `poste_budgetaire_type_id` (int) | `poste_budgetaire_type_id` | saisi |
| HT | `montant_ht` (decimal) | `montant_ht` | saisi |
| TVA % | `taux_tva` (decimal, libre) | `taux_tva` | saisi |
| TTC | — (jamais posté) | `montant_ttc` (+ `montant_tva`) | **calculé serveur** |
| Pièces | — (upload séparé) | `entity_documents` (morph) | — |
| Commentaire | `commentaire` | `commentaire` | saisi |
| ✓ / 🗑 | — | — | actions |

### Mapping du fournisseur

| Libellé affiché dans la liste | Valeur émise par le composant | Valeur stockée | Libellé back |
|---|---|---|---|
| `MADN — MADN — SAINT MARTIN DE LONDRES` (`nom — nom_legal — ville`, construit par `FournisseurController::search`) | `Number(item.id)` via `update:modelValue` | `programme_depenses_manuelles.fournisseur_id` | `Fournisseur::nom` (réaffiché par `fournisseur_nom`) |

### Clavier

Entrée valide la ligne et descend d'une ligne sur la même colonne ; sur la ligne de saisie, elle
enregistre la dépense. Tabulation reste **native** (aucun handler `@keydown.tab`), l'ordre du DOM
étant ligne par ligne. Échap rétablit la valeur d'avant édition.

### Densité — mesures

Mesuré dans un navigateur, les deux versions rendues avec le vrai CSS Tailwind, viewport
1366 × 760, hauteur prise entre le haut du conteneur de page et le haut de la première ligne de
données :

| | Avant (#1638) | Après (#1644) |
|---|---|---|
| Hauteur avant la première donnée | **542 px** | **75 px** |
| Hauteur de ligne | 29 px | **27 px** |
| Largeur table / conteneur | — | 1298 / 1298 (aucun ascenseur) |

Le bandeau jaune passe dans un « ? » à côté du titre, **texte conservé intégralement**, y compris
l'avertissement sur le double comptage (« comptée **deux fois** ») — verrouillé phrase par phrase
par un test.

## PARTIE 3 — PIÈCES JOINTES

Colonne « Pièces » : trombone + compteur, ouvrant `EntityDocumentsPanel` dans une modale sur la
dépense. Sur la ligne de saisie, le trombone est **désactivé** (`cursor-not-allowed`) avec
l'infobulle « Enregistrez la dépense pour y joindre des pièces ».

Profil documentaire, droits et pipeline `EntityDocument` du #1638 : **inchangés** (segment
`programme-depenses-manuelles`, 10 fichiers, 10 Mo). Aucune route documents touchée.

## PARTIE 4 — SUPPRESSION DOUCE

Le back existait déjà au #1638 (`destroy` + `ProgrammeDepenseManuelleService::supprimer()` qui
pose `updated_by` puis `delete()` sur un modèle `SoftDeletes`). Ce lot n'ajoute que l'icône de
corbeille par ligne et sa confirmation. **Aucune migration.** Pas de case « Afficher les
supprimées », pas de journal à l'écran (option A).

### Exclusion des lignes supprimées — PROUVÉE

Source, citée en brut :

```php
$rows = DB::table('programme_depenses_manuelles as d')
    ->join('postes_budgetaires as pb', function ($join): void {
        $join->on('pb.poste_type_id', '=', 'd.poste_budgetaire_type_id')
            ->on('pb.programme_id', '=', 'd.programme_id');
    })
    ->where('d.programme_id', $programmeId)
    ->whereNull('d.deleted_at')
    ->groupBy('pb.id')
```

Le test ne se contente pas de chercher la chaîne : il compile la requête réellement émise par le
service via `DB::pretend()` (sans base) et vérifie le prédicat. SQL obtenu, sortie brute :

    select pb.id AS poste_budgetaire_id, COALESCE(SUM(d.montant_ht), 0) AS total_ht
    from `programme_depenses_manuelles` as `d`
    inner join `postes_budgetaires` as `pb`
      on `pb`.`poste_type_id` = `d`.`poste_budgetaire_type_id`
     and `pb`.`programme_id` = `d`.`programme_id`
    where `d`.`programme_id` = 1 and `d`.`deleted_at` is null
    group by `pb`.`id`

Rappel du piège maison : `DB::table()` ignore le scope SoftDeletes d'Eloquent — d'où le
`whereNull` explicite, et d'où un test sur le SQL et non sur le code.

## VÉRIFICATIONS

### `php -l` — sortie brute

    No syntax errors detected in tests/Unit/DepensesHorsFactureTableur1644SourceTest.php
    No syntax errors detected in database/data/journal_mises_a_jour_post_2026_06_12.php

**Aucun fichier PHP applicatif n'est modifié par ce lot** : le back du #1638 couvrait déjà la
suppression douce, les documents et la validation. Les deux seuls PHP touchés sont le test et le
journal.

### Closures ajoutées — relues une par une

Une seule closure PHP ajoutée, dans le test :
`DB::pretend(function (): void { (new ProgrammeDepenseManuelleService)->totauxHtParPosteBudgetaire(1); });`
— aucune variable extérieure à capturer, donc aucun `use()` manquant possible. Les contrôles
`ReflectionClass` / DI ne détectent pas ce défaut : la relecture est la seule garantie.

### `ReflectionClass` + instanciation DI — sortie brute

    --- App\Http\Controllers\ProgrammeDepenseManuelleController
      fichier   : app\Http\Controllers\ProgrammeDepenseManuelleController.php
      traits    : App\Http\Controllers\Foncier\Concerns\ManagesFoncierEntityDocuments
      instancie : OUI (App\Http\Controllers\ProgrammeDepenseManuelleController)
    --- App\Services\ProgrammeDepenseManuelleService
      fichier   : app\Services\ProgrammeDepenseManuelleService.php
      traits    : (aucun)
      instancie : OUI (App\Services\ProgrammeDepenseManuelleService)
    --- App\Models\ProgrammeDepenseManuelle
      instancie : OUI (App\Models\ProgrammeDepenseManuelle)
      traits    : … Illuminate\Database\Eloquent\SoftDeletes
    --- relations chargees par ProgrammeDepenseManuelleService::pourProgramme()
      fournisseur            : OK (BelongsTo)
      posteBudgetaireType    : OK (BelongsTo)
      creePar                : OK (BelongsTo)
      documents              : OK (MorphMany)
      programme              : OK (BelongsTo)
      modifiePar             : OK (BelongsTo)
      posteBudgetaireType.compteComptable : OK
    --- routes du sous-onglet : les 7 routes répondent (index/store/update/destroy + 3 documents)

`FoncierControllersIntegritySourceTest` non exécuté : **aucun contrôleur Foncier n'est touché**
par ce lot.

### Tests

    Tests\Unit\DepensesHorsFactureTableur1644SourceTest : 17 passed (88 assertions)
    Tests\Unit\ProgrammeDepensesManuelles1638SourceTest : 14 passed (94 assertions)

⚠️ `pdo_sqlite` absent de ce poste : aucun test avec base. La suppression réelle et la
répercussion au bilan doivent être vérifiées en recette (tests 4 et 7).

### Journal

Entrée **mise à jour**, pas dupliquée (#1406) : même `code`
`programme-depenses-hors-facture-1638-2026-09-22`, même `sujet` `programme_financier`, texte
relu en entier pour décrire l'**état actuel** de la fonctionnalité. Aucun doublon introduit
(le seul doublon du fichier, `avis-imposition-socle-table-inline-1420-2026-09-03`, est
**préexistant** — vérifié en comparant avec la version committée).

## DÉPLOIEMENT-TEST

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

Aucune migration, aucune route ajoutée : pas de `migrate`, pas de `route:clear`.
Pas de `npm` ni de `composer` sur OVH (build Vite fait en local et committé).

1. **Test du fix** : sous-onglet DÉPENSES HORS FACTURE, taper « madn » dans la cellule
   Fournisseur, cliquer la proposition → le fournisseur **s'affiche** dans la case et part au
   serveur.
2. Saisir une ligne complète : 1 000 € HT, TVA 20 % → TTC 1 200 € calculé, Entrée enregistre.
3. Taux 7,3 % → accepté, TTC 1 073 €.
4. Modifier le montant d'une ligne existante sur place → le bilan suit.
5. Joindre deux pièces par le trombone, les rouvrir depuis la ligne.
6. Sur la ligne vide : trombone désactivé, infobulle « Enregistrez la dépense pour y joindre
   des pièces ».
7. Supprimer une ligne → elle disparaît de la liste, le total du poste au bilan revient à sa
   valeur d'origine, et
   `SELECT id, deleted_at FROM programme_depenses_manuelles WHERE id = <id>` la montre toujours
   présente avec `deleted_at` renseigné.
8. Le « ? » restitue l'intégralité de l'avertissement, double comptage compris.
9. Compte hors COMPTA / DG / ADMIN : sous-onglet absent, POST direct → 403.
   **Profil à utiliser** : un conducteur de travaux ou un membre du pôle technique
   (`GS_ENVOL` ou `GS_HECTARE` seul, sans `GS_COMPTABILITE` ni `GS_DIRECTION_GENERALE`).
   Le compte de Robin est `GS_ADMIN_ERP` : `Gate::before` y renvoie true, il ne prouve aucun
   refus.
10. BILAN, BUDGET, TVA SUR MARGE, FACTURES et leurs totaux : inchangés.
11. 1366 px et 1920 px : toutes les colonnes visibles, aucun ascenseur horizontal.
12. **Non-régression du composant fournisseur sur les deux autres écrans** : fiche acquéreur
    (notaire, notaire double minute, agence) et modale de réservation d'un lot — sélectionner
    un partenaire, enregistrer, rouvrir : la valeur est conservée.

## LEÇON

- **Une option d'interface peut détruire une donnée.** `force-selection` de PrimeVue n'est pas
  une validation : c'est un `updateModel(null)` + `emit('clear')` déclenché par l'événement natif
  `change`, donc **au blur**, donc **avant** le `click` qui sélectionne. Toute option de
  composant qui « nettoie » un champ doit être lue dans le source de la librairie avant d'être
  posée, et jamais confondue avec une garantie serveur.
- **Ne jamais vider, dans un handler d'événement, la collection qui porte l'élément cliqué.**
  `suggestions.value = []` retirait le `<li>` entre `mousedown` et `mouseup` : le navigateur
  n'émet alors aucun `click`. Le symptôme (« le clic ne fait rien ») ne ressemble pas du tout à
  la cause.
- **Un composant partagé qui « marche ailleurs » ne prouve rien.** Les trois consommateurs
  avaient le même défaut ; personne ne l'avait signalé sur les deux autres écrans parce que le
  champ y est facultatif — seul l'écran où il est **obligatoire** rendait la panne bloquante.
- **Reproduire avant de corriger.** Un banc Vite minimal montant le composant réel avec un faux
  endpoint a donné la séquence d'événements exacte en quelques minutes, là où la lecture du code
  menait à trois hypothèses fausses.
- **`DB::pretend()` est le bon outil** pour prouver qu'une requête Query Builder filtre bien
  `deleted_at` sans base de données : il compile le SQL réellement émis par le service, alors
  qu'une recherche de chaîne dans le source ne prouve que l'intention.
