---
suivi: 1511
date: 2026-09-10
sujet: Diagnostic — perte dossier/poste à la validation d’une facture Projet
chantier: factures / depot / destination-projet
type: diagnostic
statut: poussé
hash: 90652f87
fichiers:
  - docs/suivi/SUIVI_1511_diag_perte_dossier_projet_saisie.md
---

# SUIVI #1511 — PHASE 1 diagnostic : dossier/poste Projet perdus à « Valider »

## PROMPT ENVOYÉ

Diagnostic seul. Dépôt 1821 : destination PROJET persistée, `dossier_foncier_id`
et `poste_budgetaire_type_id` NULL (colonne et `ocr_raw.saisie`). Robin a vu
les champs remplis au clic sur « Valider ». Hypothèse à vérifier : les deux
clés absentes du payload, d’où `required_if` inerte. Ne pas coder.

## BASE GIT

HEAD `70446c64` (fiche #1510). origin/main à jour, pas de rebase à faire.
Aucun fichier applicatif modifié.

## FICHES LUES

- **#1492 LEÇON** — ancrage type + dossier ; `required_if` / `prohibited_unless`
  / `assertDestinationProjetCoherence` / `assertRattachementBudgetCompletPourEnvoi`.
  Cinq points Rule::in destination (masse, Outlook, draft, valider, filtre).
  Impact : les gardes existent sur le **request** ; le persist « Valider » est
  un second chemin, non recensé comme trou d’écriture.
- **#1505 LEÇON** — UI poste `dossier_foncier_id` + type ; journal à la
  sélection. Impact : le branchement parent est `v-model:dossier-id` /
  `v-model:poste-type-id` sur `useForm`.
- **#1506 LEÇON** — AutoComplete PV4 + `forceSelection` : `mousedown.preventDefault`
  + `ignoreNextClear`. Impact : le lot n’a **jamais** vérifié le payload ni
  la ligne écrite par `valider()`. C’est le trou annoncé par le prompt.
- **#1504 LEÇON** — audience RGPD du sélecteur. Hors cause.
- **#1507 LEÇON** — filtre liste sur la **colonne** FK, pas le JSON. Conséquence :
  1821 n’apparaît pas dans l’onglet Projet (colonne NULL) alors que le bouton
  Projet reste allumé (JSON destination).

## SYNTHÈSE

### A. Le payload — ce qui est branché, et où ça se casse

#### 1. Remontée enfant → parent

`DepotProjetDossierFields.vue` : `defineModel('dossierId')` et
`defineModel('posteTypeId')` (L5-6). L’AutoComplete affiche un **objet local**
`dossierSelected` (template L298-299, `v-model="dossierSelected"`). L’id
numérique est écrit dans le modèle à `commitDossierSelection` (L143-164) :
`dossierId.value = id`. Idem poste : `commitPosteSelection` L213-219,
`posteTypeId.value = Number(item.id)`.

Parent `DepotFactureSaisieForm.vue` L8564-8567 :

    <DepotProjetDossierFields
        v-else-if="form.destination === 'PROJET'"
        v-model:dossier-id="form.dossier_foncier_id"
        v-model:poste-type-id="form.poste_budgetaire_type_id"

Les deux clés sont des champs `useForm` (init L544-549). Ce n’est ni un emit
maison ni un objet partagé hors form.

Le constat Robin (champs visibles et remplis) est le **libellé AutoComplete**
(`dossierSelected` / `posteSelected`), pas une preuve réseau. Si
`form.dossier_foncier_id` est un nombre, le garde front L7605-7616 laisse
passer « Valider ».

#### 2. Chemins d’envoi

| Chemin | Construit le payload ? | Les deux clés y sont ? | Persist serveur |
|---|---|---|---|
| Brouillon PATCH `/depot-factures/{uuid}` | `buildDraftPayload` L6583-6615 : oui si `destination === 'PROJET'` | Oui, explicitement | `update()` → `applyDepotSaisieFromValidated` : **colonne + JSON** |
| « Valider » POST `/depot-factures/{uuid}/valider` | `buildValiderPayload` L7180 : `...data` (form.data()) + `poste_budgetaire_type_id` | Oui **si** le form les a (le front les exige L7605-7616). **Pas d’appel `saveDraft` avant** (contrairement à « Valider et comptabiliser » L3719) | `valider()` L2258-2301 : **n’écrit ni les colonnes ni les deux clés dans le overlay `saisie`** |
| « Valider et comptabiliser » | `saveDraftAjax` d’abord puis `buildValiderPayload` | Draft : oui. Puis engagement | Draft OK ; `DepotFactureEngagerWorkflowService` L144-162 **même oubli** que `valider()` — mais le spread `...$saisie` **conserve** ce que le draft a déjà écrit |
| Validation fiche dépôt (workflow déjà engagé) | `valider-workflow-engage` | N/A saisie complète | Pas de réécriture des FK Projet |
| Validation fiche facture `FactureController::validerFiche` | Commentaire NV1/NV2 | Pas les FK | Workflow seul |
| PATCH compta `/factures/comptabilisation/{uuid}` | `buildDraftPayload` | Oui | `applyDepotSaisieFromValidated` (L4554) : colonne + JSON |
| Masse / Outlook | `masse_destination` | Pas de dossier | PROJET **refusé** (L200, L292, `masseDestinationValidationRules` L5281-5291) |

`only()` / `except()` : aucun sur ces deux clés. `$request->validate()` des
règles draft (L943-944) et valider (L1055-1056) **les liste**. Elles ne sont
pas filtrées à la validation. Elles sont **omises à l’écriture** du chemin
`valider()`.

#### 3. Persistance `applyDepotSaisieFromValidated` (brouillon / PATCH compta)

État actuel (pas la version d’origine) : L1548 `assertDestinationProjetCoherence` ;
L1670-1675 recopie `poste_budgetaire_type_id` et `dossier_foncier_id` dans
`ocr_raw.saisie` ; L1757-1763 + `appendDirectFkColumnsToUpdates` L5875-5880
écrivent les **colonnes**. Fin de méthode : `cloturerSiValidationEnAttente`
(#1489, L1841) inchangé. **Les deux destinations (colonne et JSON) sont
branchées sur ce chemin.**

#### 4. Persistance `valider()` — point de rupture

`$saisie` = copie de `ocr_raw.saisie` **déjà en base** (L2015), souvent sans
les deux ids si aucun brouillon Projet n’a réussi.

`$depotUpdate` L2258-2294 :

- colonnes : entite, facture_id, programme, marche, fournisseur, montants,
  statut, deja_reglee, ocr_raw — **pas** `dossier_foncier_id`, **pas**
  `poste_budgetaire_type_id` ;
- `ocr_raw.saisie` : `...$saisie` puis overlay `destination`, fournisseur,
  programme, categorie, marche, type, notes, numero, flags, pénalités,
  ventilations, valideur — **pas** les deux ids.

Eloquent ne touche pas les colonnes absentes du tableau : elles restent NULL
si le draft ne les a jamais posées. L’overlay `saisie` pose `destination`
depuis `$validated` et laisse les ids à NULL (absents de l’ancien JSON).

Même trou, mêmes lignes d’overlay :

- `validerHistorique` L2806-2826
- `DepotFactureEngagerWorkflowService::engagerAvecWorkflow` L144-162

#### 5. Colonne, JSON, ou les deux ?

Les deux étaient prévus. Le brouillon écrit les deux. **« Valider » n’écrit
ni l’une ni l’autre.** Ce n’est pas un seul chemin cassé : c’est le chemin
d’engagement qui n’a jamais reçu le branchement #1492/#1505.

Ça explique l’écran actuel : bouton Projet allumé (`saisie.destination`) et
champs vides (colonnes NULL ; hydrate L6813-6821 lit d’abord la colonne).

### B. Pourquoi aucune garde n’a bloqué

#### 6. Chemin réel (déposant = valideur NV1)

`submitValidation` → POST `valider`. Auto-NV1 L2100-2103
(`$estValidateurDesigne`) → `a_comptabiliser` (L2192-2193 /
`FacturePasseParValidationNv2`). Historique attendu :
`valide_nv1_a_l_envoi` et/ou `envoye_comptabilisation` (L2399-2417).
Ce n’est **pas** le problème ; c’est le comportement annoncé.

Ce chemin **traverse** les gardes #1492 : `depotFactureValiderValidationRules`
(L2021) puis `assertRattachementBudgetCompletPourEnvoi` (L2032) qui appelle
`assertDestinationProjetCoherence` (L5151).

#### 7. `required_if:destination,PROJET` — hypothèse « inerte » : écartée pour 1821

La règle est sur le **même** tableau que `destination` required (L1055-1062).
`mergeDestinationDepuisSaisieSansDefautProgramme` (L2017) ne fait rien si
`destination` est déjà postée. `buildValiderPayload` poste `destination`
(via `...data`).

Si les ids **manquaient** du request, `required_if` **ou** l’assert L5224-5233
devraient 422 : l’assert n’a alors **aucun** fallback (dépôt 1821 n’avait
pas les colonnes). Une validation qui aboutit à `a_comptabiliser` implique
que les ids **étaient dans le request** (ou l’assert aurait jeté).

Les gardes ne sont pas inertes. Elles **passent**, puis `valider()` **n’écrit
pas** ce qu’elles ont validé.

Le `??` de l’assert (L5213-5221) est un piège **autre** : un null explicite
dans `$validated` retombe sur le dépôt. Il masquerait un payload vide
**seulement si** un brouillon avait déjà les ids. Ce n’est pas 1821 (les
deux NULL à l’arrivée).

#### 8. Portée des asserts

`assertDestinationProjetCoherence` : si destination PROJET → dossier + type
actifs, même enseigne. Sinon interdit un dossier renseigné.

`assertRattachementBudgetCompletPourEnvoi` : appelle l’assert Projet, puis
**return immédiat** si FRAIS_GENERAUX **ou PROJET** (L5153-5155). Le XOR
marché/poste/sans_marche est PROGRAMMES seulement. PROJET **est** couvert
par l’assert cohérence, pas par le XOR.

#### 9. Points destination au-delà des cinq du #1492

Les cinq existent toujours (lignes actuelles) :

1. masse `store` L200
2. Outlook L292
3. draft L934 + L943-944
4. valider L1062 + L1055-1056 (aussi `validerHistorique` L2688,
   `resolveEngagementPayloadPourValiderEtComptabiliser` L2606,
   `resolveEngagementPayloadPourValiderDejaValide` L2553)
5. filtre liste L3212 (`destination.*`, lecture)

Ajouts / portes :

- PATCH compta : mêmes règles draft (+ analytics requis) puis
  `applyDepotSaisieFromValidated` — **écriture OK**.
- `FactureController::validerFiche` : pas de saisie Projet.
- **Porte ouverte = persist `valider()` / `validerHistorique` /
  `EngagerWorkflowService`**, pas une règle manquante.

### C. Conséquence aval — dépôt 1821 comptabilisable ?

`ComptabilisationController` lit `saisie.destination` **sans** `Rule::in`
(L176 liste, L1226 / L1477 APBILL). PROJET ≠ PROGRAMMES :

- `assertProgrammeChargeAccountConfiguredPourComptabilisation` L1098-1100 :
  **return** (aucun contrôle de poste/dossier) ;
- CLASSID : branche analytics (`analytics_id`), pas le programme (L1226-1246) ;
- lignes APBILL = `saisie.lignes_comptables` déjà là. Pas de lecture de
  `dossier_foncier_id`.

Front : destination PROJET → **pas** de proposition `627000` (SaisieForm
L5755-5758).

Donc :

- `comptabiliser()` L662-677 (statut `valide`) : **aucun** contrôle Projet.
  1821 peut être marqué comptabilisé dans l’ERP sans dossier.
- `envoyerIntacct*` : pas de refus « dossier manquant ». Blocage possible
  seulement sur CLASSID / lignes / identité — **pas mesuré** pour 1821
  (dépend de `analytics_id` et `lignes_comptables` dans le JSON).
- Onglet Projet (#1507) : filtre colonne FK → 1821 **invisible** dans l’onglet,
  visible ailleurs avec destination Projet.

Étape 5 (CLASSID dossier) toujours hors périmètre ; le trou d’écriture
rend 1821 déjà dangereux avant cette étape.

### D. Ampleur

Voir SQL ci-dessous. Cursor n’a pas MySQL : **aucun chiffre**.

Si un brouillon Projet avait persisté les ids **avant** « Valider », le
spread `...$saisie` les aurait **gardés** dans le JSON et les colonnes
auraient survécu (non listées dans `$depotUpdate`). L’état 1821 (les deux
NULL) ⇒ **aucun brouillon Projet réussi**, ou un overlay qui n’a jamais eu
les clés. Cohérent avec un clic direct sur « Valider » sans Enregistrer.

## POURQUOI LE #1506 A PARAIT FONCTIONNER

Le #1506 a réparé l’**affichage** après frappe (`commitDossierSelection` +
garde `@clear`). Robin a vu le champ plein. Le POST « Valider » peut porter
les ids (form + `buildValiderPayload`). Les gardes voient les ids. La base
non. Tester la sélection sans relire colonne / `ocr_raw.saisie` après
engagement ne peut pas voir ce trou. Ce n’est pas une fausse cause : c’est
le trou que le #1506 a déclaré ne pas couvrir.

## POINT DE RUPTURE (une phrase)

`DepotFactureController::valider` (L2258-2301) fusionne `ocr_raw.saisie` et
met à jour le dépôt **sans recopier** `dossier_foncier_id` ni
`poste_budgetaire_type_id` depuis `$validated`, alors que les gardes #1492
viennent de les exiger sur ce même `$validated`.

## PHASE 2 — quoi modifier (ne pas faire ici)

1. Dans `valider()`, poser les deux clés sur **colonnes** et dans l’overlay
   `saisie` (même sémantique que `applyDepotSaisieFromValidated` L1674-1675
   et L1757-1763). Préférer extraire un helper unique plutôt que recopier
   le tableau d’overlay une quatrième fois.
2. Même correctif : `validerHistorique` L2806 ; `DepotFactureEngagerWorkflowService`
   L144-162.
3. Ne pas toucher la modale / `DepotProjetDossierFields.vue` pour **cette**
   cause (sélection déjà corrigée au #1506).
4. Ne pas toucher `Show.vue` fiche parcelle, ni les fichiers #1507/#1506
   hors nécessité.
5. Reprise 1821 : **Robin** (réécrire colonne + clé JSON à partir du dossier
   réellement choisi — non stocké). Ne pas inventer l’id.
6. Filet optionnel : après persist, relire les colonnes si destination
   PROJET (l’assert actuel ne voit pas l’écriture).
7. Le `??` de l’assert L5213-5221 : traiter `array_key_exists` pour ne pas
   masquer un null posté — utile, **pas** la cause de 1821.

## SQL POUR ROBIN (aucun chiffre ici)

Noms : `depot_factures.dossier_foncier_id`, `.poste_budgetaire_type_id`,
`.facture_id`, `.statut`, `.uuid`, `fournisseurs.nom`,
`dossiers_fonciers.reference`, `postes_budgetaires_types.code_interne`
(pas `code`). `depot_factures.validateur_id` n’existe pas.

Tous les dépôts Projet orphelins :

    SELECT
      df.id,
      df.uuid,
      df.statut,
      df.facture_id,
      df.dossier_foncier_id,
      df.poste_budgetaire_type_id,
      f.nom AS fournisseur_nom,
      df.montant_ht,
      df.montant_ttc,
      JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.destination')) AS saisie_destination,
      JSON_EXTRACT(df.ocr_raw, '$.saisie.dossier_foncier_id') AS saisie_dossier_foncier_id,
      JSON_EXTRACT(df.ocr_raw, '$.saisie.poste_budgetaire_type_id') AS saisie_poste_budgetaire_type_id
    FROM depot_factures AS df
    LEFT JOIN fournisseurs AS f ON f.id = df.fournisseur_id
    WHERE JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.destination')) = 'PROJET'
      AND (
        df.dossier_foncier_id IS NULL
        OR df.poste_budgetaire_type_id IS NULL
        OR JSON_EXTRACT(df.ocr_raw, '$.saisie.dossier_foncier_id') IS NULL
        OR JSON_TYPE(JSON_EXTRACT(df.ocr_raw, '$.saisie.dossier_foncier_id')) = 'NULL'
        OR JSON_EXTRACT(df.ocr_raw, '$.saisie.poste_budgetaire_type_id') IS NULL
        OR JSON_TYPE(JSON_EXTRACT(df.ocr_raw, '$.saisie.poste_budgetaire_type_id')) = 'NULL'
      )
    ORDER BY df.id;

Clés réellement envoyées / stockées pour 1821 :

    SELECT
      df.id,
      df.uuid,
      df.statut,
      df.facture_id,
      df.dossier_foncier_id,
      df.poste_budgetaire_type_id,
      JSON_KEYS(JSON_EXTRACT(df.ocr_raw, '$.saisie')) AS cles_saisie,
      JSON_EXTRACT(df.ocr_raw, '$.saisie') AS saisie
    FROM depot_factures AS df
    WHERE df.id = 1821
       OR df.uuid = 'e0117096-d6a7-43b3-9db8-98c0607674da';

Historique + workflow (chemin NV1 à l’envoi) :

    SELECT
      fh.id,
      fh.depot_facture_id,
      fh.facture_id,
      fh.user_id,
      fh.action,
      fh.commentaire,
      fh.champs_modifies,
      fh.created_at
    FROM facture_historique AS fh
    WHERE fh.depot_facture_id = 1821
    ORDER BY fh.id;

    SELECT
      wv.id,
      wv.facture_id,
      wv.niveau,
      wv.statut,
      wv.validateur_id,
      wv.date_assignation,
      wv.date_validation,
      wv.commentaire,
      wv.created_at
    FROM workflow_validations AS wv
    INNER JOIN depot_factures AS df ON df.facture_id = wv.facture_id
    WHERE df.id = 1821
    ORDER BY wv.id;

Attendu si le diagnostic tient : actions `valide_nv1_a_l_envoi` et/ou
`envoye_comptabilisation` ; pas de `modifie` (brouillon) portant les deux
ids ; `cles_saisie` avec `destination` et sans ids (ou ids JSON null).

## CE QUI N’A PAS PU ÊTRE ÉTABLI

- Payload HTTP réel du clic 1821 (pas de HAR, pas de MySQL).
- Présence d’un PATCH brouillon avant « Valider » (l’historique le dira).
- `analytics_id` / `lignes_comptables` de 1821 → Intacct passerait ou non.
- Nombre d’autres dépôts Projet orphelins.
- sqlite skipped : **aucune valeur de preuve**.

## DÉPLOIEMENT-TEST

Aucun code applicatif. Après push de la fiche :

    git pull origin main

Pas de migrate, pas de journal:sync, pas de route:clear, pas de npm.
Ne pas modifier le dépôt 1821.

## LEÇON

Valider les champs sur le request ne sert à rien si le `update` d’engagement
reconstruit `ocr_raw.saisie` par liste blanche et oublie les clés. Le
brouillon (`applyDepotSaisieFromValidated`) et « Valider » sont **deux
persists**. Un fix UI (#1506) et des `required_if` (#1492) peuvent tous
deux « marcher » pendant que l’engagement jette les valeurs. Toujours
relire colonne **et** JSON après le POST qui change le statut.
