---
suivi: 1633
date: 2026-09-22
sujet: Parcelles — filtres Statut / Destination, tri colonnes simples, « À vérifier », champ Infos
chantier: module-foncier
type: feature
statut: poussé
hash: c2ef11c7
fichiers:
  - app/Support/Foncier/FoncierReferentiels.php
  - app/Services/Foncier/ParcelleProspectionQuery.php
  - app/Http/Controllers/Foncier/ProspectionController.php
  - app/Http/Controllers/Foncier/ParcelleController.php
  - app/Models/Parcelle.php
  - database/migrations/2026_09_22_210000_add_infos_urbanisme_to_parcelles.php
  - resources/js/Pages/Foncier/Prospection/Index.vue
  - resources/js/Pages/Foncier/Parcelles/Show.vue
  - tests/Unit/ParcelleFiltresTri1633SourceTest.php
  - database/data/journal_mises_a_jour_post_2026_06_12.php
  - public/build/
  - docs/suivi/SUIVI_1633_parcelles_filtres_destination_infos.md
---

# SUIVI #1633 — filtres Statut / Destination, tri, « À vérifier », champ Infos

## PROMPT ENVOYÉ

Page /foncier/parcelles mode « Par parcelle » : ajouter une liste déroulante
Statut (toutes les valeurs) et une liste Destination, le tri / la recherche /
le filtre par colonne sur les colonnes SIMPLES (Commune, Référence,
Propriétaire(s), Surface, Zonage, Référent), une destination « À vérifier »
distincte de « À définir », et un champ libre « Infos » en haut du bloc
Urbanisme de la fiche. Colonnes calculées hors périmètre. Ne pas toucher aux
pastilles, à la déduction des statuts, au mode « Par propriétaire » ni à
l'import.

## FICHES LUES

- **#1569** — statut déduit : 9 valeurs, colonne `parcelles.statut`,
  `etat_foncier` / `type_foncier` conservées mais **non lues**.
- **#1568** — destination : 7 valeurs, colonne `parcelles.destination`,
  reprise legacy depuis `type_foncier` / `etat_foncier`.
- **#1070 / #1119** — options de filtre calculées sur le jeu VISIBLE (clone
  avant filtres actifs) : ne pas déplacer ce calcul.
- **#1208** — compteurs d'état = ensemble filtré hors filtre états.
- **#1283 / #1288** — vue complète prospection : groupes en **brut**
  (`m365GroupNames`), jamais `hasAnyM365Group` / `isAdminERP` / Gate ; le
  cloisonnement est porté par le scope de visibilité + `mon_secteur`.
- **#1107** — édition inline : liste blanche serveur des champs.
- **#1463** — bloc Urbanisme empilé de la fiche parcelle.

## AVANT MODIFICATION (constat)

### Chaîne de la liste
| Élément | Valeur constatée |
|---|---|
| URL réelle | `/foncier/prospection?mode=parcelle` — `ParcelleController::index` ne fait que **rediriger** vers `foncier.prospection.index` |
| Contrôleur | `ProspectionController::index` → `payloadParcelle()` |
| Service de requête | `ParcelleProspectionQuery` (`paginate` / `applyFilters` / `filterOptions` / `normalizeFilters`) |
| Composant Vue | `resources/js/Pages/Foncier/Prospection/Index.vue` (un seul composant pour les 3 modes) |
| Sous-liste commune | `ProspectionParcellesSublist.vue` + `listForCommune()` |

### Statut : déduit PUIS persisté
`ParcelleStatutService::deduire()` calcule le statut à partir des faits
(sans issue → remplacée → détenue → promesse signée → offre → dossier vivant →
dossier clos → repérée) et `recalculerIds()` / `recalculerTout()` **écrivent**
le résultat dans la colonne `parcelles.statut`. La liste et les pastilles lisent
donc une colonne, pas un calcul à la volée.

**Comment filtrer sans casser la déduction** : filtrer la colonne
`parcelles.statut` (`whereIn`), exactement ce que fait déjà `applyFilters`. Rien
à toucher dans le service de déduction. `STATUT_CEDEE` n'est **jamais produit**
aujourd'hui (aucune source d'acte de cession) : la valeur existe au référentiel,
son compteur vaut 0 — c'est volontaire et documenté #1569.

### 🔴 Les deux filtres existaient déjà côté serveur
Constat important : `ParcelleProspectionQuery::applyFilters` gère **déjà**
`etats[]` (statut) et `destination`, et `ProspectionController` lit déjà ces deux
paramètres. Côté front, `destination` existait aussi — mais **dans le panneau
« Filtres (N) » replié par défaut**, donc invisible : d'où le constat « il
n'existe aucune liste déroulante ». Le statut, lui, n'était atteignable que par
les pastilles. Ce lot n'a donc pas créé de mécanisme de filtrage : il a **exposé
dans la barre principale** ce qui existait, et ajouté le tri / la recherche par
colonne, qui n'existaient pas du tout en mode parcelle.

### Valeurs exhaustives
**Statuts — `FoncierReferentiels::STATUTS` (constante PHP, 9 valeurs)** ;
colonne `parcelles.statut` (varchar, migration `2026_09_16_140000_add_statut_to_parcelles`),
**pas un enum SQL** : `reperee`, `opportunite`, `en_negociation`,
`sous_compromis`, `detenue`, `cedee`, `remplacee`, `exploree`, `sans_issue`.

**Destinations — `FoncierReferentiels::DESTINATIONS` (constante PHP, 7 avant ce
lot)** ; colonne `parcelles.destination` (varchar, migration
`2026_09_16_120000_add_destination_to_parcelles`) : `a_definir`,
`support_operation`, `bouchon`, `reserve`, `cession`, `echange`,
`retrocession`. Confirme les 7 valeurs citées par le #1568.

### Colonnes de `parcelles`
`Schema::getColumnListing('parcelles')` **n'a pas pu être exécuté** : MySQL local
refuse la connexion (`SQLSTATE[HY000] [2002]`, constat récurrent #1626 → #1632).
Colonnes relevées sur le dump `hectareenvol.sql` (CREATE TABLE L3172-3201)
**croisées avec** `Parcelle::$fillable` et les migrations postérieures au dump :
`id`, `section_cadastrale`, `numero`, `code_insee_commune`,
`surface_cadastrale_m2`, `surface_arpentee_m2`, `date_arpentage`, `bornage`,
`date_bornage_effective`, `centroide_lat`, `centroide_lng`, `zonage_plu`,
`lieu_dit`, `constructible`, `risque_inondable`, `risque_feu_foret`,
`bati_present`, `etat_foncier`, `type_foncier`, `societe_detentrice_id`,
`statut_cadastral`, `commentaire`, `createur_id`, `created_at`, `updated_at`,
**`deleted_at`**, `deleted_by`, **`parcelle_vivante` (générée STORED)** — plus,
postérieures au dump : `destination`, `statut`, `responsable_id`,
`occupation_absente_at`, `bati_absent_at`, `sans_issue_at`.
⚠️ Le dump est en retard (antérieur au 16/09) : il ne contient ni `statut` ni
`destination`, pourtant bien en base. Même piège que #1628.

### Patron des filtres Commune / Constructible
Barre principale : `Select` lié à un `ref` (`codeInsee`, `constructible`), avec
`@update:model-value="applyFilters"` → `router.get('/foncier/prospection', filterParams(), { preserveState: true, replace: true })`.
Les filtres vivent dans la **query string** ; le serveur les renvoie normalisés
dans `filters`, et un `watch(() => props.filters)` resynchronise les refs. Les
filtres sont donc conservés au rechargement et au partage du lien. C'est ce
patron qui a été suivi.

### Contenu du panneau « Filtres (N) »
Mode parcelle : Département, **Destination**, Inondable, Occupation, Bâti,
« détenue par le groupe », « mon secteur », « grouper par chargé ». Le badge
compte ces filtres avancés (ni la recherche, ni Commune / Constructible, ni les
états).

## SYNTHÈSE

### Les deux listes déroulantes
- **Statut** : `MultiSelect` dans la barre principale, lié au **même** `ref`
  `etatsCoches` que les pastilles, alimenté par `filter_options.etats` — donc par
  le `COUNT(*) … GROUP BY statut` qui produit les pastilles. Les libellés
  affichent le compteur (« Détenue (39) »). **Sélectionner « Détenue » et cliquer
  la pastille « Détenue 39 » exécutent strictement le même `whereIn('parcelles.statut', …)`** :
  aucune divergence possible, les deux mécanismes partagent la source **et** le
  paramètre d'URL.
- **Destination** : `Select` **remonté** du panneau replié vers la barre
  principale (une seule occurrence dans le composant, vérifiée par test) ; retiré
  du décompte « Filtres (N) » puisqu'il n'est plus un filtre avancé.
- Combinaison : tous les filtres s'ajoutent en `AND` à la recherche libre,
  inchangée.

### Tri des colonnes simples
Nouvelle constante `ParcelleProspectionQuery::TRIS_PARCELLE` =
`commune, reference, proprietaires, surface, zonage, referent` (liste blanche :
tout autre `sort` est ignoré, y compris `statut`). `appliquerTri()` :

| Colonne | Implémentation | Pourquoi |
|---|---|---|
| Commune | sous-requête corrélée `communes.nom` | trier le nom, pas le code INSEE |
| Référence | `section_cadastrale`, puis `LENGTH(numero)`, puis `numero` | section d'abord ; `LENGTH` avant la valeur pour que 5 précède 302 même si les numéros ne sont pas tous zéro-remplis |
| Propriétaire(s) | sous-requête `MIN(COALESCE(raison_sociale, nom))` sur les détentions non closes | libellé affiché, premier propriétaire |
| Surface | `COALESCE(surface_arpentee_m2, surface_cadastrale_m2)` | **numérique** (colonnes `decimal`), surface réellement affichée |
| Zonage | `COALESCE(zonage_plu, '')` | vides regroupés |
| Référent | sous-requête `users.name` via `createur_id` | la colonne affiche le créateur |

Trois garde-fous :
1. **Aucun GROUP BY** ajouté (only_full_group_by actif en prod, non reproduit par
   SQLite en local) : les tris inter-tables passent par des **sous-requêtes
   corrélées**, jamais par un JOIN — qui aurait aussi dupliqué les lignes.
2. **Départage stable** `ORDER BY … , parcelles.id` : sans lui, deux lignes de
   même clé peuvent changer d'ordre entre deux pages et une ligne être répétée
   ou perdue à la pagination.
3. Tri par défaut (commune, section, numéro) **inchangé** si aucun tri demandé.

`deleted_at` : la requête part de `Parcelle::query()` (SoftDeletes Eloquent,
`parcelles.deleted_at is null` vérifié dans le SQL compilé). Les tables visées
par mes sous-requêtes (`communes`, `users`, `proprietaires`) ont été **vérifiées
sans SoftDeletes** (`class_uses_recursive`), donc aucun filtre `deleted_at` à y
ajouter — vérifié, pas supposé.

### Recherche par colonne
`appliquerFiltresColonnes()` : `c_reference` (section, numéro ou « section
numéro »), `c_proprietaire` (nom / prénom / raison sociale via `whereHas`),
`c_zonage`, `c_referent` (`whereHas` sur le créateur), `c_surface_min` /
`c_surface_max` (bornes **numériques** sur la surface affichée, valeur non
numérique ignorée). Ces champs sont regroupés dans un encart « Recherche par
colonne » du panneau Filtres, avec un bouton « Effacer », et comptent dans le
badge.

### Colonnes calculées — hors périmètre, explicitement
**Statut (déduit), Occup., Bâti, Acte, Valorisation ne sont pas triables dans ce
lot.** Elles restent affichées et filtrables par leurs listes dédiées (Statut,
Occupation, Bâti). Raison : leur valeur n'est pas une colonne triable simple
(Occup. et Bâti combinent une relation et une date de constat, Acte est un
`withCount`, Valorisation une sous-requête « plus récente »), et un tri correct
exigerait de matérialiser ces expressions — travail et risque propres, donc lot
séparé. Le front ne propose aucun tri sur ces colonnes (test dédié).

### Destination « À vérifier »
`DESTINATION_A_VERIFIER = 'a_verifier'`, libellé « À vérifier », ajoutée au
référentiel en 2ᵉ position. **Libellé proposé pour lever l'ambiguïté** :
`a_definir` s'affiche désormais **« Destination non arbitrée »** — proposition,
pas une imposition ; la **valeur stockée reste `a_definir`**, donc aucune
parcelle existante n'est touchée et aucune migration de données n'est nécessaire.
Saisie et filtre partent du même `optionsDestinations()` : la nouvelle valeur est
immédiatement disponible sur la fiche, à la création et dans le filtre.

### Champ « Infos »
Colonne `parcelles.infos_urbanisme` **TEXT nullable** (champ de commentaire),
migration idempotente (`Schema::hasColumn`), **aucune colonne générée ajoutée** —
la table porte déjà `parcelle_vivante` (générée STORED) et MySQL 8 refuse une
colonne générée posée après une colonne à FK (erreur 1215) ; ici on ajoute une
colonne ordinaire, ce qui ne touche pas à l'existante. Pas de DDL en transaction.

Édition : `InlineEditableField` `type="textarea"`, **en premier** dans le bloc
Urbanisme (position vérifiée par test : avant « Zonage PLU »). Droits
**exactement** ceux des autres champs d'urbanisme : `:editable="peut_gestion"`
côté écran et la même liste blanche serveur `champsInlineAutorises()` +
`parcelleFieldRules()` (`nullable|string|max:5000`, plafond de sécurité).
Chaîne vide normalisée en `NULL` : un champ vidé n'affiche rien de parasite.

### Droits — filtres et vue complète
Aucun filtre n'élargit le périmètre : tous n'ajoutent que des `where` à une
requête déjà restreinte par le scope de visibilité parcelle et, le cas échéant,
`applyMonSecteurFilter`. Le bandeau « Vue complète — vous voyez toutes les
parcelles (groupe GS_ADMIN_ERP) » reste piloté par
`utilisateurAAccesTotalProspection()` qui lit `m365GroupNames()` **brut** — ni
Gate, ni `isAdminERP`, ni `hasAnyM365Group` (appels absents, vérifiés par test).
**Le compte de Robin (GS_ADMIN_ERP) ne prouve donc rien sur le cloisonnement** :
le test 12 exige un compte tiers **sans** GS_ADMIN_ERP / GS_DIRECTION_GENERALE /
GS_GEMME_PDG.

## TABLEAU DE MAPPING OBLIGATOIRE

### Filtre Statut (9 valeurs, aucune ajoutée ni renommée)
| Libellé affiché | Valeur postée (`etats[]`) | Valeur stockée (`parcelles.statut`) | Libellé back |
|---|---|---|---|
| Repérée (n) | `reperee` | `reperee` | `STATUT_REPEREE` |
| Opportunité (n) | `opportunite` | `opportunite` | `STATUT_OPPORTUNITE` |
| En négociation (n) | `en_negociation` | `en_negociation` | `STATUT_EN_NEGOCIATION` |
| Sous compromis (n) | `sous_compromis` | `sous_compromis` | `STATUT_SOUS_COMPROMIS` |
| Détenue (n) | `detenue` | `detenue` | `STATUT_DETENUE` |
| Cédée (n) | `cedee` | `cedee` | `STATUT_CEDEE` — jamais produit à ce jour (#1569) |
| Remplacée (n) | `remplacee` | `remplacee` | `STATUT_REMPLACEE` |
| Explorée (n) | `exploree` | `exploree` | `STATUT_EXPLOREE` |
| Sans issue (n) | `sans_issue` | `sans_issue` | `STATUT_SANS_ISSUE` |

### Filtre / saisie Destination (8 valeurs : 1 ajoutée, 1 renommée)
| Libellé affiché | Valeur postée | Valeur stockée (`parcelles.destination`) | Libellé back | Statut |
|---|---|---|---|---|
| **Destination non arbitrée** | `a_definir` | `a_definir` | `DESTINATION_A_DEFINIR` | **renommé** (était « À définir ») — valeur inchangée |
| **À vérifier** | `a_verifier` | `a_verifier` | `DESTINATION_A_VERIFIER` | **ajouté** |
| Support d'opération | `support_operation` | `support_operation` | `DESTINATION_SUPPORT_OPERATION` | inchangé |
| Bouchon | `bouchon` | `bouchon` | `DESTINATION_BOUCHON` | inchangé |
| Réserve | `reserve` | `reserve` | `DESTINATION_RESERVE` | inchangé |
| Cession | `cession` | `cession` | `DESTINATION_CESSION` | inchangé |
| Échange | `echange` | `echange` | `DESTINATION_ECHANGE` | inchangé |
| Rétrocession | `retrocession` | `retrocession` | `DESTINATION_RETROCESSION` | inchangé |

### Tri et filtres par colonne
| Affiché | Paramètre posté | Colonne / expression | Libellé back |
|---|---|---|---|
| Clic en-tête Commune | `sort=commune&dir=asc|desc` | sous-requête `communes.nom` | `appliquerTri` |
| Clic en-tête Référence | `sort=reference` | `section_cadastrale`, `LENGTH(numero)`, `numero` | idem |
| Clic en-tête Propriétaire(s) | `sort=proprietaires` | `MIN(COALESCE(raison_sociale, nom))` | idem |
| Clic en-tête Surface | `sort=surface` | `COALESCE(surface_arpentee_m2, surface_cadastrale_m2)` | idem |
| Clic en-tête Zonage | `sort=zonage` | `zonage_plu` | idem |
| Clic en-tête Référent | `sort=referent` | sous-requête `users.name` (`createur_id`) | idem |
| Référence (recherche) | `c_reference` | section / numéro / concat | `appliquerFiltresColonnes` |
| Propriétaire(s) | `c_proprietaire` | `proprietaires.nom|prenom|raison_sociale` | idem |
| Zonage | `c_zonage` | `zonage_plu` | idem |
| Référent | `c_referent` | `users.name` | idem |
| Surface mini / maxi | `c_surface_min` / `c_surface_max` | `COALESCE(arpentee, cadastrale)` | idem |
| Infos (fiche) | `champ=infos_urbanisme&valeur=…` | `parcelles.infos_urbanisme` (TEXT) | `champsInlineAutorises` |

## VÉRIFICATIONS

`php -l` : OK sur les 6 fichiers PHP + le journal.

Reflection / DI + SQL compilé (sortie brute en synthèse) : contrôleurs et service
résolus par le conteneur, `authorize` présent sur `ParcelleController`, liste
blanche de tri effective (`surface` accepté, `statut` refusé), 9 statuts et 8
destinations, `infos_urbanisme` en liste blanche inline et en `fillable`, SQL de
tri et de filtres inspecté.

Tests : `ParcelleFiltresTri1633SourceTest` (11 tests) → **11 passed (128
assertions)**. Non-régression #1540 / #1569 / #1573 / #1581 / #1580 / #1530 +
`FoncierControllersIntegritySourceTest` (#1103) → **45 passed (1435
assertions)**.

⚠️ **Aucun test avec base** (`pdo_sqlite` absent) : la migration, le tri réel et
le cloisonnement n'ont pas été exécutés contre une base. Les tests 2, 7 et 12 en
recette sont donc indispensables.

⚠️ Piège rencontré (3ᵉ fois cette série) : mes assertions SQL utilisaient les
backticks MySQL alors que l'environnement de test compile en guillemets ; et un
`assertStringNotContainsString('hasAnyM365Group')` échouait sur un **commentaire**
préexistant (#1283). Assertions rendues indifférentes au guillemet, et portées
sur les **appels** (`->hasAnyM365Group(`) et non sur le texte.

## DÉPLOIEMENT-TEST

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

Migration obligatoire (`parcelles.infos_urbanisme`). Pas de `route:clear`
(aucune route ajoutée). Pas de `npm` / `composer` sur OVH.

1. Liste Statut : les 9 valeurs, dont Cédée, Remplacée, Explorée, Sans issue.
2. Filtrer « Détenue » → même nombre que la pastille « Détenue 39 ».
3. Liste Destination : les 8 valeurs, dont « À vérifier ».
4. Combiner statut + destination + recherche libre → résultat cohérent.
5. Tri Surface : ordre numérique (5 m² avant 302 m²).
6. Tri Référence : AT 0687 avant BS 0157, BS 0157 avant BS 0158.
7. Tri conservé au changement de page et au rechargement (présent dans l'URL).
8. Saisir « À vérifier » sur une parcelle → conservé après rechargement.
9. Parcelles en `a_definir` inchangées, affichées « Destination non arbitrée ».
10. Champ Infos : saisie, enregistrement, rechargement, affiché en haut du bloc
    Urbanisme.
11. Champ Infos vide : aucune mention parasite.
12. **Compte tiers** (sans GS_ADMIN_ERP / GS_DIRECTION_GENERALE / GS_GEMME_PDG) :
    ne voit que ses parcelles, quels que soient les filtres et le tri.

## LEÇON

Avant d'ajouter un filtre « absent », vérifier s'il n'est pas simplement
**invisible** : ici `destination` existait de bout en bout, mais dans un panneau
replié par défaut. Le bon geste était de le remonter, pas de le recréer — et de
retirer son décompte du badge « Filtres (N) » pour que le compteur reste vrai.

Un tri sur une donnée d'une autre table se fait par **sous-requête corrélée**,
jamais par un JOIN : le JOIN duplique les lignes et, dès qu'une agrégation
s'invite, `only_full_group_by` (actif en prod, muet sous SQLite) casse la requête.
Et tout tri paginé exige un **départage stable** sur la clé primaire.

Un référentiel se renomme à l'affichage, jamais en valeur : « À définir » devient
« Destination non arbitrée » sans qu'aucune parcelle ne bouge.
