---
suivi: 1797
date: 2026-10-06
sujet: Diagnostic lenteur avis d'imposition
chantier: patrimoine
type: diagnostic
statut: poussé
hash: 4a730868
fichiers:
  - docs/suivi/SUIVI_1797_diag_lenteur_avis_imposition.md
---

# SUIVI #1797 — diagnostic lenteur, avis d'imposition

Phase 1. Aucun code. Aucune écriture en base. Aucun appel externe.

Fiches lues : #1438 (les 311 lignes sont peintes en entier, le conteneur du tableau est forcé en `overflow: visible`), #1439 (l'historique se charge ligne par ligne, pas avec la page), #1441 (le récap est une requête agrégée, au clic). Le commentaire encore présent dans la page cite ce volume de 311 lignes, mesuré à l'import de début septembre. Le dump local `hectareenvol.sql` (16/09) ne contient que le schéma, pas les lignes.

La page de production a renvoyé l'écran de connexion Microsoft. Temps et taille réseau de la première ligne `avis-imposition` : non mesurés depuis ici.

## 1. Chargement

### a. Route et écran

GET `/patrimoine/avis-imposition` (`routes/web.php:791-792`), nom `foncier.patrimoine.avis-imposition.index`.

Middleware du groupe (`:789`) : `feature.permission:avis_imposition.lecture`, `foncier.module`. Le contrôleur revérifie `AvisImpositionDroits::peutAcceder` (`AvisImpositionController.php:41-42` et `:401-404`).

Méthode `index` (`:39-59`). Services injectés mais non appelés à l'affichage : `AvisImpositionDocumentService`, `AvisImpositionExportService`, `AvisImpositionRecapService` (`:30-34`). L'affichage ne fait qu'une lecture Eloquent et `serializeRow`.

Page `resources/js/Pages/Foncier/Patrimoine/AvisImposition/Index.vue`. Composants : DataTable PrimeVue, `InlineEditableField`, boutons, deux dialogues (historique, récap). Pas d'autre page Vue.

### b. SQL

`index` (`:44-50`) :

```php
AvisImposition::query()
    ->with(['documents' => fn ($q) => $q->orderByDesc('id')])
    ->orderByDesc('id')
    ->get()
```

Deux lectures, pas une par ligne :

1. `avis_impositions` où `deleted_at` est nul (SoftDeletes), tri `id` descendant, toutes les colonnes.
2. `avis_imposition_documents` pour ces identifiants, tri `id` descendant.

Pas de biens, pas de propriétaires, pas de N+1. `get()` sans `paginate()` : toutes les lignes partent d'un coup.

Colonnes lues en trop puis jetées par `serializeRow` (`:422-436`) : verrou, auteur de suppression, motif, horodatages. `observation` et `commentaire` (TEXT) partent bien dans la page, ils sont éditables.

Index présents dans le schéma du 16/09 (`hectareenvol.sql:5217-5237`), alignés sur les migrations :

- `avis_impositions` : PRIMARY `id`, `fk_avis_imp_del_user`, `fk_avis_imp_ver_user`. Pas d'index sur `deleted_at`, `structure`, `departement`, `commune`.
- `avis_imposition_documents` : PRIMARY `id`, clé sur `avis_imposition_id`, clé sur `uploaded_by_user_id`.
- `avis_imposition_historique` : PRIMARY `id`, clés sur `avis_imposition_id`, `user_id`, `impersonator_id`. Cette table n'est pas lue à l'affichage.

Le tri SQL utilise la clé primaire. Les filtres et le tri de l'écran sont dans le navigateur (`Index.vue:93-103`, `:488-510`). Un index sur `commune` ou `structure` ne accélère pas cet écran au volume actuel.

### c. Appels externes

Aucun pendant l'affichage. Pas de SharePoint, Graph, Sage, IGN, ni Anthropic. Les pièces sont sur le disque local (`AvisImpositionDocumentService.php:23`, `DISK = local`). `serialize` (`:135-144`) n'envoie que l'identifiant, le nom, la taille, le type et la date. Le fichier n'est lu qu'au téléchargement (`AvisImpositionController.php:356-372`).

### d. Poids envoyé

Props Inertia (`:52-59`) : `rows` (toutes les lignes), `fields` (21 métadonnées), trois booléens, le jeton CSRF.

Chaque ligne (`serializeRow`) : `id`, les 21 champs de `champsInlineAutorises` (`AvisImposition.php:89-117`), et le tableau `documents` (métadonnées seulement).

Volume connu à l'import : 311 lignes (#1438, commentaire `Index.vue:838`). Des lignes ont pu être ajoutées depuis (bouton « Ajouter une ligne »). Ordre de grandeur du JSON `rows` : quelques centaines de Ko une fois gzipé plutôt que plusieurs Mo, tant que les commentaires restent courts et que les PDF ne sont pas dedans. La commande OVH affiche `props_rows_octets`.

Le premier affichage évalue aussi les props partagées de `HandleInertiaRequests::share` (`:58-133`), dont le compteur de validations (`:243`, `whereHas` sur les factures), les tâches (`:359`) et les alertes (`:345`). Ce coût existe sur tous les écrans. Il ne explique pas qu'on travaille lentement une fois la page ouverte.

### e. Calculs et rendu

Côté PHP, `index` ne calcule pas de totaux. Le récap agrégé n'est pas appelé.

Côté navigateur, le DataTable reçoit `localRows` en entier (`:105`, `:489`). Pas de `paginator`, pas de `scrollHeight`, pas de virtual scroller. Le style force le conteneur PrimeVue en `overflow: visible` (`:859-861`) pour que les 311 lignes restent dans le défilement de la page (#1438).

Chaque ligne peint 20 `InlineEditableField` (tous les champs sauf TLVH, qui est une case) plus la colonne Documents. À 311 lignes : environ 6 200 composants d'édition, plus une ligne de filtres, plus un pied de totaux recalculé sur les lignes filtrées (`:132-144`).

Les filtres sont `CONTAINS` dans le navigateur, à chaque frappe (`:525-532`, `@input="filterCallback()"`), sans délai. Le tri est aussi local (`sortable` sur chaque colonne).

## 2. Actions

Aucune action ne renvoie la page Inertia. Chacune répond en JSON et met à jour le tableau déjà en mémoire. Le ressenti lent après une modification vient du re-rendu de ces milliers de cellules, pas d'un rechargement des 311 lignes.

| Action | Appel | Rechargement |
| --- | --- | --- |
| Modifier un champ | PATCH `/patrimoine/avis-imposition/{id}` (`web.php:806`, `update` `:89`). Corps `{ champ, valeur }`. Réponse `{ ok, valeur }` (`:159-163`). `InlineEditableField.vue:131` | JSON. La ligne locale est mise à jour (`Index.vue:218-219`) |
| TLVH | même PATCH (`:222-234`) | JSON |
| Ajouter une ligne | POST `/patrimoine/avis-imposition` (`store` `:62`) | JSON, la ligne est ajoutée en tête (`:176-184`) |
| Supprimer | DELETE `.../{id}` (`destroy` `:166`) | JSON, la ligne est retirée du tableau (`:191-205`) |
| Filtrer, trier | aucun appel | recalcul local de tout le tableau |
| Récap | GET `.../recap` (`:274`, `AvisImpositionRecapService::lignesAgregees` `:79`) | JSON, une agrégation, dialogue |
| Historique | GET `.../{id}/historique` (`:201`, limite 100) | JSON, une ligne |
| Ajouter un document | POST `.../{id}/documents` (`:307`) | JSON + écriture disque local, pas de relecture de la page |
| Retirer un document | DELETE `.../documents/{id}` | JSON |
| Télécharger | GET `.../documents/{id}/download` | fichier seul, au clic |
| Excel / PDF | POST `.../export/excel` ou `.../pdf` (`:238`, `:256`) | le serveur relit les identifiants envoyés (plafond 2 000, `:292`) et génère le fichier. La page reste |

## 3. Mesure (OVH, lecture seule)

Coller en SSH, dans le répertoire de l'application. Compte les tables, montre les index, rejoue la lecture de `index` et estime le JSON des lignes. N'écrit rien. N'appelle ni Sage ni SharePoint.

php artisan tinker --execute='DB::enableQueryLog(); $avis = DB::table("avis_impositions")->count(); $actifs = DB::table("avis_impositions")->whereNull("deleted_at")->count(); $docs = DB::table("avis_imposition_documents")->count(); $hist = DB::table("avis_imposition_historique")->count(); echo "avis=".$avis." actifs=".$actifs." documents=".$docs." historique=".$hist.PHP_EOL; foreach (["avis_impositions","avis_imposition_documents","avis_imposition_historique"] as $table) { echo "INDEX ".$table.PHP_EOL; foreach (DB::select("SHOW INDEX FROM ".$table) as $idx) { echo $idx->Key_name." ".$idx->Column_name.PHP_EOL; } } DB::flushQueryLog(); $t = microtime(true); $rows = App\Models\AvisImposition::query()->with(["documents" => function ($q) { $q->orderByDesc("id"); }])->orderByDesc("id")->get(); $ms = round((microtime(true) - $t) * 1000, 1); $log = DB::getQueryLog(); $octets = 0; foreach ($rows as $row) { $octets += strlen((string) json_encode($row->only(array_merge(["id"], App\Models\AvisImposition::champsInlineAutorises())))); foreach ($row->documents as $doc) { $octets += strlen((string) json_encode(["id" => $doc->id, "nom" => $doc->nom_original, "taille" => $doc->taille, "mime" => $doc->mime])); } } echo "requetes=".count($log)." ms=".$ms." lignes=".$rows->count()." octets=".$octets." Ko=".round($octets / 1024, 1).PHP_EOL; foreach ($log as $q) { echo round($q["time"], 1)." ms ".$q["query"].PHP_EOL; }'

## 4. Conclusion

Du plus coûteux au moins coûteux.

1. Rendu de toutes les lignes dans le navigateur (environ 6 200 champs éditables, défilement forcé sans virtualisation). C'est ce qui bloque l'affichage après l'arrivée des données, et chaque modification, filtre ou tri. Gain attendu : le plus gros. N'afficher que les lignes visibles rend l'écran utilisable.
2. Filtres et tri recalculés sur tout le tableau à chaque frappe, sans délai. Gain attendu : la saisie dans un filtre cesse de figer la page.
3. JSON de toutes les lignes au premier chargement. Gain attendu : modéré (centaines de Ko). Utile en complément, insuffisant seul.
4. Les deux lectures SQL et l'absence d'index de filtre. Gain attendu : faible à ce volume (la commande doit le confirmer, typiquement bien en dessous de la seconde). Un index ne corrige pas le rendu.
5. Compteurs du menu sur le premier chargement, partagés avec les autres écrans. Gain attendu : sur le délai avant le premier octet, pas sur le travail dans le tableau.
6. Export Excel ou PDF, au clic seulement. Gain attendu : nul pour l'affichage courant.

Plan (non codé).

1. Virtualiser le tableau, ou le paginer, pour ne peindre que les lignes visibles. Garder le défilement horizontal (#1438).
2. Laisser le PATCH JSON : il ne recharge déjà pas la page. Éviter de repeindre tout le tableau après une cellule.
3. Attendre la fin de la frappe avant de filtrer.
4. Charger les documents au clic de la ligne, pas dans le premier JSON.
5. Ne pas ajouter d'index tant que la mesure OVH montre un SQL court.
6. Laisser le récap et l'historique hors du chargement initial.

## DÉPLOIEMENT-TEST

Aucun déploiement. Fiche seule.

## LEÇON

À quelques centaines de lignes, le SQL de cette page est déjà en deux lectures. Le coût est le nombre de champs éditables peints d'un coup. Un index ou un cache SQL ne rendrait pas l'écran fluide.
