---
suivi: 1550
date: 2026-09-15
sujet: Diagnostic — lenteur d’affichage d’une facture dans la boîte de dépôt
chantier: factures / depot / performance
type: diagnostic
statut: poussé
hash: f9c6dd52
fichiers:
    - docs/suivi/SUIVI_1550_diag_lenteur_depot.md
---

# SUIVI #1550 — PHASE 1 diagnostic : lenteur boîte de dépôt

## PROMPT ENVOYÉ

Diagnostic seul. Sélectionner une facture dans la boîte de dépôt est
lent (plusieurs secondes avant aperçu PDF + formulaire). Hypothèse
n°1 : l’OCR Anthropic est rejoué à chaque ouverture. Retracer la
route, le payload, les SQL / N+1, le PDF, le front, une méthode de
mesure prod, classer les solutions. Aucune migration, aucun code
applicatif.

## BASE GIT

HEAD = `origin/main` = `66296d25` (chore hash fiche #1549).
Working tree propre hors `hectareenvol.sql` (non commité).
Aucun rebase. Aucun fichier applicatif modifié.

## FICHES LUES

- **#1516 LEÇON** — clic liste = `GET /depot-factures/{uuid}/data` ;
  iframe PDF = `GET …/pdf` (disk local, `canViewDepot`) ;
  `url_fichier` = `temporaryUrl` 20 min sur le **liste**. Impact :
  le clic n’est pas un `Inertia.visit` de page Show ; c’est un
  XHR `data` + un GET `pdf` en parallèle.
- **#1533 LEÇON** — 32 requêtes pour 7 lignes venaient d’un N+1
  (secteur par commune), pas des relations affichées. Même réflexe
  ici : compter ce qui est **calculé pour tous les marchés**, pas
  seulement le dépôt cliqué.
- **#1190** (via `programmesInitiauxPourSaisie` L4169-4187) — le
  catalogue programmes est déjà à la demande (`/programmes/search`).
  Les **marchés** n’ont **pas** suivi : tout le catalogue + KPI
  part à chaque clic.
- **#1361 / #1373** — OCR à l’upload, timeout 25 s, circuit lot.
  Confirmé : ce n’est pas le chemin d’affichage.
- **#1188** — `DepotFactureSaisieForm.vue` déjà ~9 100 L à l’époque.
  Compte actuel : **11 434** lignes (mesure locale 2026-09-15).

Aucune fiche #1549 n’a changé l’écran dépôt.

---

## 1. L’OCR EST-IL REJOUÉ À L’AFFICHAGE ?

### Verdict : **NON**

L’API Anthropic **ne part pas** au clic dans la liste. Le résultat
est lu depuis `ocr_raw` déjà persisté.

### Ce que fait le clic

`resources/js/Pages/DepotFactures/Index.vue` `selectRow` L914-945 :

1. Pose `selectedUuid`.
2. `axios.get('/depot-factures/' + uuid + '/data')`.
3. Remplit `saisiePayload` ; le formulaire s’affiche ensuite.

Route : `GET /depot-factures/{uuid}/data`
(`routes/web.php` L396) → `DepotFactureController::data` L720-732
→ `depotSaisiePagePayload` L4256-4511.

Aucun appel à `analyser`, `analyserPdf`,
`DepotFactureClaudeAnalysisService::analyzeAndPersist` dans `data`
ni dans `depotSaisiePagePayload` (grep des trois noms : uniquement
`store` / `finalizeDepotAfterLocalPdfStored` L483 et `analyser`
L868).

### Quand l’OCR part vraiment

| Déclencheur | Fichier | Lignes |
|-------------|---------|--------|
| Dépôt PDF (upload / Outlook) | `finalizeDepotAfterLocalPdfStored` → `analyserPdf(..., silent: true)` | L426-483, L6431-6434 |
| Bouton « Analyser avec Claude » (statut `depose`) | `Index.vue` `analyser()` → `POST …/analyser` | L603-635, L1087-1092, L1498-1503 |
| Bouton « Réanalyser » (confirmation) | `peutReanalyserClaude` + `executerReanalyse` | L578-600, L1095-1101 |
| Endpoint | `DepotFactureController::analyser` | L838-890 |

`analyserPdf` L6431-6434 = unique pont vers
`DepotFactureClaudeAnalysisService::analyzeAndPersist` L39-137 :

- lit le PDF **local** (`Storage::disk('factures')->get`) ;
- pose `OutboundApiLogContext` `entity_type = depot_facture`,
  `api_operation = depot_facture_ocr` (L42-45) ;
- timeout 25 s (`OCR_TOTAL_TIMEOUT_SECONDS` L29) ;
- **persiste** dans `ocr_raw` (`extraction` + `saisie` + suggestions,
  L87-115).

Cache : **oui**, c’est `ocr_raw` en base. L’affichage **relit**
(`'ocr_raw' => $depot->ocr_raw` L4319). Pas de recalcul.

Le bouton « Analyser / Réanalyser » est le **seul** déclencheur
manuel. L’automatisme n’existe **qu’au dépôt** du PDF (sauf saisie
historique L462-467, sauf circuit lot `skipOcr` L475).

Aucun observer / cron / `data()` ne rappelle Claude.

### Preuve croisée SQL `api_logs`

Les appels OCR sont journalisés par `LogOutboundHttpCall`
(`app/Listeners/LogOutboundHttpCall.php` L134-154) :
`service = anthropic`, `provider = claude`,
`operation = depot_facture_ocr` (surcharge du path `/v1/messages`
via le contexte L104-108), `entity_type = depot_facture`,
`entity_id = depot_factures.id`.

Si l’hypothèse « rejoué à l’affichage » était vraie, un dépôt
consulté N fois aurait ~N lignes OCR. Le code dit le contraire ;
la requête ci-dessous le **prouve** en prod.

```sql
-- Appels OCR par dépôt (entity_id = depot_factures.id)
SELECT
  al.entity_id AS depot_id,
  COUNT(*) AS n_appels_ocr,
  SUM(CASE WHEN al.success = 1 THEN 1 ELSE 0 END) AS n_ok,
  ROUND(AVG(al.duration_ms), 0) AS duree_moy_ms,
  MAX(al.duration_ms) AS duree_max_ms,
  SUM(al.input_tokens) AS tokens_in,
  SUM(al.output_tokens) AS tokens_out,
  MIN(al.created_at) AS premier,
  MAX(al.created_at) AS dernier
FROM api_logs al
WHERE al.entity_type = 'depot_facture'
  AND al.operation = 'depot_facture_ocr'
GROUP BY al.entity_id
ORDER BY n_appels_ocr DESC
LIMIT 30;
```

```sql
-- Distribution : 1 appel (dépôt seul) vs 2+ (réanalyses)
SELECT n_appels, COUNT(*) AS n_depots
FROM (
  SELECT al.entity_id, COUNT(*) AS n_appels
  FROM api_logs al
  WHERE al.entity_type = 'depot_facture'
    AND al.operation = 'depot_facture_ocr'
  GROUP BY al.entity_id
) t
GROUP BY n_appels
ORDER BY n_appels;
```

```sql
-- Garde-fou : OCR « orphelins » (contexte oublié) — host Anthropic
SELECT COUNT(*) AS n_sans_entity
FROM api_logs al
WHERE al.service = 'anthropic'
  AND al.operation IN ('depot_facture_ocr', '/v1/messages')
  AND al.entity_type IS NULL;
```

Interprétation attendue (à coller, pas inventée) : la masse des
dépôts a **1** appel ; 2+ = clics Réanalyser ou retry upload.
Si un dépôt a 20+ appels **sans** réanalyses, alors seulement on
réouvrirait l’hypothèse. Le code actuel ne le permet pas via
`/data`.

---

## 2. LE POIDS DU PAYLOAD

`data()` renvoie **le même** `depotSaisiePagePayload` que la page
Show (`buildSaisiePayload` L4195-4198). Ce n’est **pas** Inertia au
clic (XHR JSON). La page liste, elle, est Inertia une fois
(`index` L126-162).

### `ocr_raw` entier : OUI

L4319 : `'ocr_raw' => $depot->ocr_raw`. Tout le JSON colonne.

Structure posée à l’analyse (`analyzeAndPersist` L87-98) :

- `extraction` : copie brute Claude (schéma L147-176, ~20 champs
  dont indices / objet / mode règlement — peu utiles à l’UI sauf
  `syncOcrFieldsFromExtraction` L5468 qui lit `extraction`) ;
- suggestions (`*_suggere`, `fournisseur_match_methode`,
  `destination`, `destination_indices`) ;
- `saisie` : **état du formulaire** (lignes TVA, retenues,
  ventilations, valideur…) — ça grossit avec la saisie, ce n’est
  pas du « bruit OCR ».

Taille moyenne / max : **inconnue ici** (pas MySQL). Requête §7.

### Catalogues renvoyés à CHAQUE clic (L4401-4498)

| Clé payload | Source | Indispensable à l’affichage initial ? |
|-------------|--------|--------------------------------------|
| `depot.*` (yc `ocr_raw`) | le dépôt | Oui (saisie + extraction pour le bandeau OCR) |
| `documents_joints` | relation | Oui si joints |
| `workflow_modification`, `commentaires_workflow` | WV | Utile si déjà engagé ; rare en boîte |
| `liberation_rg_plafond` | 1 marché | Seulement si `marche_id` déjà posé |
| `reglages_fournisseur` | 1 fournisseur | Utile au préremplissage |
| `programmes` | **1** programme rattaché (#1190) | Oui |
| `entites` | **toutes** les entités | Déjà dans `index` L142 — **doublon** |
| `utilisateurs` | Graph caché 60 min (`GraphApiService` L213-239) | Déjà dans `index` L143 — **doublon** ; JSON potentiellement gros |
| `fournisseurs` | `FournisseurSelectionOptions::forSelect` — **tous les actifs** | Select fournisseur : oui un jour, pas forcément la liste complète à chaque clic (autocomplete existe ailleurs) |
| `marches` | **TOUS** les marchés + KPI par marché | Le select ne montre que ceux du **programme courant** (`marchesFiltres` L1708-1714). Le reste est du ballast. |
| `postes_charge_directe_par_programme` | tous les postes `applicable_budget` groupés | Utile au changement de programme ; trop large au 1er paint |
| `postes_budgetaires_types` | catalogue enseigne | Idem |
| `services`, `etablissements`, `classes` | catalogues Intacct | Onglet compta / lignes ; pas le 1er cran saisie |
| `comptes_comptables`, `comptes_bancaires` | catalogues GL | Idem |
| `comptes_comptables_ap` | 7 settings (`ErpComptesComptablesService::resolved` L25-37) | Idem |
| `financial_entities` + `comptes_*_options_by_societe` | **par société** : chaque `build()` recharge établissements + referentiel + comptes (`ComptabilisationBankCompteOptions` L16-34, même schéma cartes) | Uniquement si « déjà réglée » / compta dans le formulaire |
| `types_factures`, `categories_frais_generaux` | petits catalogues | Oui, légers |
| `can_*` flags | droits | Oui |

CIE : **pas** dans ce payload. `cieOperationsCandidates` L765-788
est un GET à part, au clic dialogue (`ouvrirDialogCie`). Bon
précédent de chargement à la demande.

Avoirs proposables : GET au clic (`chargerAvoirsProposables`
SaisieForm L2535). Idem.

### Mesure taille

Cursor n’a pas la prod. À coller (DevTools) : taille de la
réponse `GET /depot-factures/{uuid}/data` (en-tête
`Content-Length` ou colonne Size). Compléter par SQL §7
(`LENGTH(ocr_raw)`, `COUNT(*)` marchés / fournisseurs).

Ordre de grandeur **attendu** (pas une mesure) : le JSON marchés
avec KPI + tous les fournisseurs + comptes par société **domine**
`ocr_raw` dès qu’il y a plus de quelques dizaines de marchés.

---

## 3. LES REQUÊTES SQL

Pas de `query_count` sur cet écran (le compteur « N requête(s) SQL »
n’existe que sur Prospection / propriétaires —
`Prospection/Index.vue`, #1533). Donc **aucun chiffre runtime**
ici. Le N+1 se lit dans le code.

### Boucle marchés — le même type de problème que #1533, en pire

`depotSaisiePagePayload` L4406-4443 :

```
Marche::query()
  ->with([fournisseur, posteBudgetaireType.compteComptable, avenants, situations])
  ->withCount('sousTraitants')
  ->orderBy('reference')
  ->get([...colonnes...])
  ->map(mapMarchePourSaisieDepotPayload)
```

Pour **chaque** marché, `mapMarchePourSaisieDepotPayload` L4203-4250 :

1. `kpiAvancementMarche` L403-431 :
   - `totalHtDepotsMarche` → `depotsPourAgregation` L828-872 :
     **tous les dépôts du marché** avec `ocr_raw` + ventilations ;
   - `totalTtcDepotsMarche` : **la même agrégation une 2e fois** ;
   - `montantReprisePourFiltreStatuts` **deux fois**
     (`MarcheRepriseMontantsService::totauxPourMarche`).
2. `plafondHorsRetenues` L49-87 → `ciePrevuPourMarche` L482-510 :
   2 requêtes CIE **par marché**.

Soit, si `M` = nombre de marchés et `D_m` = dépôts du marché m :

- 1 SELECT marchés (+ eager avenants/situations) ;
- **2 × M** chargements de dépôts (HT + TTC) ;
- **2 × M** reprises ;
- **2 × M** CIE.

Ce n’est pas 32 requêtes pour 7 lignes : c’est **proportionnel à
tous les marchés de la base**, à chaque clic, **même si le dépôt
n’a pas encore de programme**. Le front jette ensuite tout sauf
`programme_id` courant (L1708-1714).

`depotsPourAgregation` charge `ocr_raw` (L871) uniquement pour
convertir HT/TTC (libération RG #1538). Coût I/O JSON **par dépôt
de chaque marché**.

### Autres requêtes répétées / lourdes (une fois par clic, pas N+1 marchés)

- `Fournisseur::actifs()` complet.
- `PosteBudgetaire` tous programmes (`buildPostesChargeDirecteParProgrammePayload` L6082-6118).
- `CompteComptable` entier + encore `512%` + encore **tous** les
  comptes dans chaque `Comptabilisation*CompteOptions::build`.
- `Etablissement::actifs()` **par société** (banque **et** carte).
- `financialEntitiesGrouped` **par société** (2 SELECT cloned).
- `Entite::orderBy('nom')` plusieurs fois (`entites` L4401,
  `comptesPaiementOptionsParSociete` L6478, `PaiementReferentielOptions::entites` si appelé).
- 7 `settings` pour les comptes AP (`resolved()` + `one()` L42).
- Doublon : `DepotFactureDoublonPayload` seulement si
  `doublon_detecte` (L4280, L16-18) — OK.
- Graph utilisateurs : cache Laravel 60 min (`CACHE_STORE` défaut
  `database`, `config/cache.php` L18) — **pas** un appel Graph à
  chaque clic après le premier, mais le JSON est **re-sérialisé**.

### Ce qui n’est pas un N+1 dépôt

Le dépôt cliqué est eager-loadé une fois (L4258-4270). Ça n’explique
pas plusieurs secondes.

---

## 4. LE PDF

### Aperçu (colonne milieu)

`Index.vue` L1527-1529 (et modal L1747-1748) :

```
/depot-factures/${selectedUuid}/pdf#toolbar=1&navpanes=0&view=FitH
```

`DepotFactureController::pdf` L3062-3081 :

- disk **`factures`** = **local**
  (`config/filesystems.php` L50-54 :
  `storage_path('app/private/factures')`) ;
- `response()->file($path)` ;
- **pas** SharePoint, **pas** Graph.

Pas de cache applicatif. Le navigateur peut cacher l’URL stable
`/depot-factures/{uuid}/pdf` (pas de query-string). Changer de
facture = autre UUID = **nouveau** téléchargement. Un PDF de
plusieurs pages pèse sur le temps **iframe**, **en parallèle** du
`/data` (le spinner « Chargement… » L1570 ne couvre que le JSON
saisie, pas le PDF).

### `url_fichier` 20 minutes (#1516)

`DepotFacture::getUrlFichierAttribute` L781-791 :
`temporaryUrl(..., now()->addMinutes(20))`, catch → `null`.

Disk `factures` : driver `local` **sans** `serve => true`. Sur ce
driver, `temporaryUrl` **lève** en général → attribut **null**.
La liste l’expose quand même (`formatBoiteDepotListeItem` L3896)
pour l’icône téléchargement L1433-1440 — **pas** pour l’iframe.

**Non régénéré au clic saisie** : `data()` pose aussi
`url_fichier` L4323 (même accessor, même échec probable). L’aperçu
n’en dépend pas.

**Pas d’appel Graph à la sélection** pour le PDF.

---

## 5. LE FRONT

`DepotFactureSaisieForm.vue` : **11 434** lignes (mesure locale).
Importé par `Index.vue` L17.

### Remontage

`Index.vue` L1620-1623 :

```
<DepotFactureSaisieForm
  v-if="saisieFormVisible"
  :key="saisiePayload.depot.uuid"
```

À chaque changement d’UUID : **démontage + remontage complet**,
pas une réhydratation du même instance. Tous les `watch(...,
{ immediate: true })` rejouent (plusieurs dizaines, grep `watch(`
dans le fichier).

`saisiePayload.value = null` **avant** le GET (L919) : le formulaire
disparaît pendant l’attente — d’où la sensation « écran vide
plusieurs secondes ».

### Bundle

`public/build/manifest.json` : `DepotFactures/Index.vue` est un
`isDynamicEntry` qui **importe** `DepotFactureSaisieForm.vue`.
Chargé **une fois** à l’entrée Inertia `/depot-factures`. Les clics
suivants sont de l’axios : **pas** de re-téléchargement du bundle.

### Calculs au montage

Après arrivée du JSON : filtres `marchesFiltres`, sync OCR
(`syncOcrFieldsFromExtraction`), watchers destination / retenues /
valideur, calculateur d’écritures côté JS. Lourds **après** le
réseau. Le goulot déclaré (plusieurs secondes **avant** d’afficher)
coincide d’abord avec **l’attente `/data`** (et éventuellement le
PDF), pas avec le parse Vue seul — sauf payload énorme à parser.

---

## 6. MÉTHODE DE MESURE (prod, reproductible)

Le compteur SQL de pied de page **n’existe pas** ici. Ne pas
l’attendre. Mesurer avec le navigateur + SQL lecture.

### A. Temps serveur vs transfert vs rendu

1. Ouvrir `/depot-factures` (liste déjà chargée).
2. F12 → Network → Disable cache.
3. Cliquer une facture **représentative** (PDF > 1 Mo si possible,
   et une petite).
4. Relever **deux** requêtes nées du clic :

| Requête | Colonne à noter |
|---------|-----------------|
| `GET /depot-factures/{uuid}/data` | Waiting (TTFB) = PHP+SQL ; Content download = JSON ; Size |
| `GET /depot-factures/{uuid}/pdf` | Waiting ; Size ; durée totale |

5. Timing : TTFB `data` >> 1 s → goulot serveur (SQL marchés).
   TTFB court + Size > 1–2 Mo → goulot payload.
   `pdf` Size élevé + download long → goulot fichier, **indépendant**
   de l’OCR.

Répéter 3 clics sur la **même** facture : si `data` reste lent,
ce n’est pas un cache Graph froid. Si le 1er clic est plus lent
puis stable, noter le cache Graph 60 min.

### B. OCR (pour infirmer définitivement)

Pendant les clics : **aucune** requête vers `api.anthropic.com`
dans Network (l’OCR est server-side). Contrôle : coller les SQL
§1 ; `n_appels_ocr` ne doit pas augmenter d’un clic d’ouverture.

### C. SQL de volume (contexte de payload)

```sql
SELECT COUNT(*) AS n_marches FROM marches;

SELECT COUNT(*) AS n_fournisseurs_actifs
FROM fournisseurs
WHERE statut = 'actif';
-- Fournisseur::actifs() = statut = actif (pas de soft delete)
```

```sql
SELECT
  COUNT(*) AS n_depots,
  ROUND(AVG(LENGTH(ocr_raw)), 0) AS ocr_raw_moy_octets,
  MAX(LENGTH(ocr_raw)) AS ocr_raw_max_octets
FROM depot_factures
WHERE deleted_at IS NULL
  AND ocr_raw IS NOT NULL;
```

```sql
SELECT COUNT(*) AS n_entites FROM entites;
SELECT COUNT(*) AS n_comptes FROM comptes_comptables;
SELECT COUNT(*) AS n_postes FROM postes_budgetaires;
```

### D. Avant / après une correction

Même utilisateur, même facture, même machine, Disable cache.
Noter TTFB + Size de `/data` (et Size de `/pdf` inchangé si on ne
touche pas le fichier). Cible qualitative PHASE 2 : TTFB `/data`
sous ~300 ms sur un dépôt sans marché, Size JSON nettement sous
le catalogue marchés×KPI.

Pas de `query_count` tant qu’on n’en ajoute pas au JSON `data`
(petit hook de mesure, pas une refonte).

---

## 7. SOLUTIONS (gain / effort / risque)

Ne pas modifier le résultat OCR ni les données saisies.
Pas de Redis. Pas de queue. Pas de suppression de fonction.

### Défauts à corriger (gains rapides)

| Piste | Gain estimé | Effort | Risque |
|-------|-------------|--------|--------|
| **1. Ne plus envoyer / calculer tous les marchés à chaque clic.** Filtrer par `programme_id` du dépôt (vide si pas de programme) ; au changement de programme, GET dédié (même idée que `/programmes/search` #1190). **Ne plus appeler `kpiAvancementMarche` pour le catalogue** : le select n’a besoin que de référence / poste / fournisseur / % retenues. KPI + plafond = marché **sélectionné** seulement. | **Très élevé** (supprime le N+1 × M). Explique à lui seul des secondes. | Moyen (1 lot, tests saisie marché / hors marché / changement programme) | Oublier un champ du select ; préremplissage OCR `marche_id_suggere` hors programme chargé → GET de repli |
| **2. Catalogues stables hors du GET `/data`.** `entites` / `utilisateurs` déjà sur `index`. Fournisseurs, comptes, classes, services, établissements, options banque/carte par société : 1 fois à l’entrée de la page **ou** endpoints lazy (modèle CIE / avoirs). `/data` = dépôt + programmes initiaux + marches du programme. | Élevé (taille JSON + SQL catalogues) | Moyen | Comptabilité dans le formulaire (déjà réglée) doit encore recevoir les options au moment du cran |
| **3. Couper la double agrégation HT+TTC et la double reprise** même si on garde un sous-ensemble de marchés (`kpiAvancementMarche` L418-429). | Moyen si 1 reste | Petit | Régression totaux avancement |

### Optimisations (après 1–2)

| Piste | Gain | Effort | Risque |
|-------|------|--------|--------|
| **4. `ocr_raw` allégé** : envoyer `saisie` + `extraction` (nécessaire L5468) + suggestions ; pas d’autres clés mortes. Ne **pas** jeter `extraction`. | Faible à moyen (selon LENGTH max) | Petit | Un bandeau qui lirait une clé oubliée |
| **5. En-têtes cache HTTP sur `GET …/pdf`** (immutable par uuid). | Faible (2e ouverture du même PDF) | Petit | PDF remplacé sans changer d’uuid (rare) |
| **6. Ne pas nullifier le formulaire pendant `/data`** (garder l’ancien jusqu’à arrivée) **ou** skeleton. | Perception | Petit | Risque de saisie croisée si pas de `:key` — le `:key` uuid reste obligatoire |
| **7. `query_count` + `Duration-Ms` dans la réponse `/data`** (mesure avant/après, comme Prospection). | Zéro user, énorme pour piloter | Très petit | Ne pas le laisser en prod visible à tous (flag admin) |

### Refonte (pas en premier)

| Piste | Gain | Effort | Risque |
|-------|------|--------|--------|
| Découper `DepotFactureSaisieForm.vue` (11 434 L) | Maintenabilité ; peu d’effet sur le TTFB `/data` | Très élevé | Zone commune disputée ; collisions lots |
| Worker / Redis pour OCR ou KPI | Interdit par l’hébergement | — | — |

**Ordre recommandé PHASE 2** : piste 1 (marchés / KPI) d’abord,
éventuellement collée à 3 ; puis 2 si Size reste gros ; 4 et 7 en
accompagnement. **Pas** de découpage du formulaire comme levier
principal. **Pas** de « fix OCR à l’affichage » : le défaut n’existe
pas.

---

## SYNTHÈSE

```markdown
SUIVI #1550 — lenteur boîte de dépôt

1. OCR rejoué à l’affichage ? NON.
   Clic = GET /depot-factures/{uuid}/data (Index.vue selectRow
   L914, DepotFactureController::data L720) puis iframe
   GET …/pdf L1529. Anthropic seulement à l’upload
   (finalizeDepotAfterLocalPdfStored L483) et POST /analyser
   L838. Résultat relu dans ocr_raw L4319.
   SQL preuve : api_logs entity_type=depot_facture
   operation=depot_facture_ocr — un clic d’ouverture ne doit
   pas incrémenter le compteur.

2. Cause principale : payload /data calcule et envoie TOUS les
   marchés avec kpiAvancementMarche + plafond CIE chacun
   (N+1 dépôts×2 + reprises×2 + CIE×2 par marché), plus tous
   les fournisseurs et catalogues comptables. Le select ne
   filtre qu’ensuite par programme (SaisieForm L1708-1714).
   ocr_raw part entier (bruit extraction réel mais secondaire).

3. PDF : disk local, pas SharePoint/Graph. url_fichier 20 min
   = icône liste, souvent null (temporaryUrl local). Pas la
   cause Graph.

4. Front : formulaire 11434 L remonté via :key=uuid. Bundle
   chargé une fois. Secondaire au TTFB /data.

Mesure : F12 Network, TTFB+Size de /data et /pdf, 3 clics
même facture. SQL COUNT marches / LENGTH(ocr_raw). Pas de
compteur SQL en pied sur cet écran.

PHASE 2 : (1) marchés du programme + KPI du marché choisi
seulement ; (2) catalogues hors /data ; pas de refonte Vue
en premier ; ne pas « corriger » un OCR qui ne se rejoue pas.
```

## DÉPLOIEMENT-TEST

Aucun code. Fiche seule.
OVH : `git pull origin main`. Pas de migrate / journal / build.

## LEÇON

La lenteur « dès qu’on clique une facture » n’est pas l’OCR :
l’OCR est borné à l’upload et au bouton, persisté dans
`ocr_raw`. Le GET `/data` rejoue le **catalogue métier complet**
(marchés × agrégats dépôts), alors que l’UI ne montre que les
marchés du programme. Même piège que #1533 (coût hors de ce
qui est affiché). Les programmes ont déjà le lazy-load #1190 ;
les marchés n’ont pas suivi. Mesurer TTFB `/data`, pas une
hypothèse Anthropic.
