## Project Memory

> Auto-genere par /memorize le 2026-05-19, maj /memorize-update le 2026-07-31. Ne pas editer manuellement.
> Documentation complete dans `.memory/`

- **Stack** : Laravel 9 + 7 cores Rootsud + PostgreSQL/PostGIS + Laravel Mix 6
- **Fichiers source** : 945
- **Couverture** : 100% (945/945)

### Inventaire (recompte 2026-07-31)
- **92 controllers** (49 racine + 14 `Parcel/` + 29 virtuels Swagger), **534 routes web**, **425 vues Blade** (dont `acquisition/` **101**), **62 JS** + **91 SCSS** + **104 modules widget** (Rollup separe), 10 helpers autoloades, 4 observers, 4 services, 3 commandes locales, 14 tests
- ⚠ **Menu Prospection SUPPRIME** : `ProspectingController`, `Parcel/TabProspectionController`, `RequestController`, `WidgetConfigController`, les 12 vues `resources/views/prospecting/` et **toutes** les routes `prospecting/*` n'existent plus — remplaces par le menu **Acquisition** (leads). Les sections « prospecting » encore presentes dans `.memory/routes.md` sont de l'historique
- **Middlewares metier** : `planaccess:LABEL`, `capability:KEY`, `hoguet`, `portal.token`, `TrackPortalVisit` — ⚠ les alias sont enregistres dans **`AppServiceProvider::boot`**, PAS dans `app/Http/Kernel.php` (le bootstrap bind le Kernel du core, le Kernel local est ignore)

### Architecture
- **iPhone, lot 1 de l'audit mobile (2026-11-05)** : compte rendu `/home/julio/www/.insights/mobile-audit-2026-11-01/IPHONE-LOT1-README.md`. (5.1) champs à 16 px sur iOS seulement : `client_portal.scss` (espace client) et partiel `includes/_ios_input_zoom.blade.php` inclus dans `client_portal/claim`, `nps/form`, `nps/detractor`, `nps/passive` (⚠ pas dans `client_portal/verify` : code à 28 px), règle du core pour l'application, widgets `src/css/base.js` (`max(16px, var(--ip-input-size))`). (6e.1) 🟥 `color-mix` avec `var()` : une couleur posée avant n'est pas un repli ; mixin `with-color-mix` (`_design-tokens.scss`), 15 blocs (`_content-bar`, `_lead-pilotage`, `_lead-redesign` bandeau succès, `acquisition`, `_opportunity`, `_setting-agency`), vues de l'espace client et de la fenêtre de création de widget, `content-bar.js` (`scopeTint`, mélange calculé), widgets (`rgba(var(--ip-primary-rgb), .08)`). (5.2) **`window.openInNewTab(url, {title, label, icon})`** (`resources/js/open-document.js`, style `_open-doc-sheet.scss`) pour toute ouverture d'onglet APRÈS une réponse du serveur : onglet bloqué (Safari sur iPhone, ou génération longue) = encart « Votre document est prêt » avec un vrai lien, application mobile = lien confié au pont `OPEN_FILE` du core ; 19 appels (`phase2-actions.js`, `client-portal-admin.js`, `cr-visite.js`, itinéraire de `tab-info.js`, `setting-reviews.js`, quittance, mandat notarial). Ne plus appeler `window.open` hors du geste. Bancs `ios/zoom_verify.py` 22/22, `ios/colormix_ab.py`, `ios/colormix_static.py`, `ios/open_doc_verify.py` 11/11. Déploiement : `git pull` (bundles des widgets commités) + `npm run prod` + `view:clear`, après les tags core et core-city.
- **Automatisations planifiées prêtes à être activées (2026-11-04)** : en production l'interrupteur « Activer les automatisations » (`status_schedule`, /administration › Projet › Réglages) est éteint et aucun cron ne lance `schedule:run` : aucune tâche de `routes/console.php` n'y a jamais tourné, son activation lancera tout d'un coup (premier passage = rattrapage). Trois corrections avant l'activation. (0) `leads:cold-followup-suggest` (core-city-user) ne finissait pas en production et y créait des pipelines par défaut même en simulation (parcours de tous les comptes + `Lead_status::getEffectiveFor`) : réécrite sur les dossiers, banc `.insights/cold-followup-scan-2026-11-04.php` 4/4 ; simulation de production après déploiement : 23 774 dossiers, **22 658 tâches à créer d'un coup** (23 089 anciennes prospections, 23 751 dossiers sans contact, mesure `.insights/sql-dossiers-froids-prod-2026-11-04.sql`) → sélection bornée dans core-city-user (contact, activité de moins d'un an, 10 par compte et par jour) et `routes/console.php` ne planifie la tâche que si cette version est installée (`->when(fn () => defined(ColdFollowupSuggestCommand::class . '::MAX_PER_OWNER'))`). (1) Point hebdomadaire aux vendeurs et bailleurs (`leads:vendor-weekly-report`) en choix de l'agent : interrupteur « Point hebdomadaire à vos vendeurs et bailleurs » (Paramètres › Préférences, bloc 5 de la section Calendrier, `data-toggle-setting="vendor_weekly_report_enabled"`, défaut désactivé, clé ajoutée à `AgencyController::PREFERENCE_KEYS`), moteur dans core-city-user (cf. son CLAUDE.md). (2) `portal:expire-inactive` ne révoque plus que les liens d'espace client SANS date de fin (`whereNull('expires_at')`) : les 88 liens existants ont une date de fin prolongée de 90 jours à chaque visite (`Client_portal_token::extendExpiryIfNeeded`), donc un lien inutilisé expire déjà seul ; l'ancienne règle ne coupait en plus QUE les durées allongées volontairement, dont les 180 jours d'une lettre de prospection (`ensureProspectionPortalToken`, qui repousse aussi la date d'un espace réutilisé sans changer sa date de création : un QR imprimé la veille pouvait être coupé la nuit suivante). Vérifié : banc `.insights/vendor-weekly-report-optin-2026-11-04.php` 11/11, rendu de la vue dans 4 états, clic réel sur l'interrupteur (`POST /parametre` 200, réglage 1 puis 0, compte remis à l'identique, 0 erreur console), banc `.insights/portal-expire-prospection-2026-11-04.php` 5/5 (ancienne règle : lien de prospection coupé ; nouvelle : gardé). Déploiement : tag core-city-user + `composer update` (site ET administration) + `queue:restart`, puis site `git pull` + `view:clear`.
- **Mention « … proposée par Infoparcelle » dans le code à copier des widgets (2026-11-03, plan SEO, levier 13)** : `App\Services\Widgets\WidgetEmbedCode::credit($type)` ajoute après la balise `<script>` une ligne `<p>` (« Estimation immobilière / Simulation DPE / Score d'opportunité de vente / Recherche de biens / Demande de devis proposée par **Infoparcelle** », lien vers `config('widgets.credit_url')` = `WIDGET_CREDIT_URL`, défaut `https://www.infoparcelle.fr/widgets`). Le widget s'insère AVANT son script (`engine.js` `insertBefore`) : la ligne s'affiche sous le widget, dans le HTML de la page du client, hors du Shadow DOM, donc lisible par les moteurs et les assistants IA. Texte du lien = la marque seule (règles de Google sur les liens de widgets), visible ; une phrase sous le code l'annonce au client. Branchée aux 11 constructions du code : `_distribution_section` + `_form_{estimation,dpe,opportunite,recherche,devis}` en PHP et en JavaScript (changement de clé, `@json`). Non touché : la page de démonstration `/widget/test`. Vérifié : code rendu pour les 5 types, page de test avec le vrai code (widget de 98 à 520 px, mention à 529 px, 0 erreur console), 5 formulaires compilés. Les codes déjà collés chez les clients ne changent pas : la mention n'apparaît que sur les nouveaux. Déploiement : `git pull` + `view:clear` + `config:clear`.
- **Calcul en direct pour l'outil « Localiser une annonce » de la vitrine (2026-11-02)** : `POST /interne/outils/localiser-annonce` (`VitrineAdvertLocateController::compute`, `throttle:30,1`, sans session, cookie ni CSRF, comme `/.well-known/assetlinks.json`). Appel serveur à serveur signé : en-têtes `X-Outils-Ts` + `X-Outils-Signature` = HMAC-SHA256(`localiser-annonce|{propertie_id}|{ts}`, `config('advert_locate.vitrine_secret')` = `OUTILS_INTERNAL_SECRET`, sinon `APP_KEY`), signature périmée après 2 min (403 sinon). Score `ok` déjà présent = réponse immédiate ; sinon `AdvertLocateScore::dispatchSync([$id])` (même calcul que les workers : score, top 10 et opportunité « annonce localisée » écrits) sous verrou de cache par annonce (un seul calcul pour deux visiteurs). Réponse `{status}` (`ok`, `no_result`, `error`, `missing` 404, `busy` 503). Journal INFO `[vitrine-locate] calcul en direct` avec la durée. Mesuré : 0,4 à 0,9 s. ⚠ Le calcul tourne avec la configuration `advert.locate.*` de l'APPLICATION (variantes `ADVERT_LOCATE_TUNER`, version) : la garder identique à celle d'administration, sinon l'empreinte diffère de celle des workers. Déploiement : `git pull` + `route:clear` (+ `config:clear`).
- **Aperçus d'essai et documents à l'unité (2026-11-02, lot 3 du plan outils gratuits)** : pendant l'essai, fiche parcelle, plans et avis de valeur partent avec le filigrane « Essai gratuit, document non valable » (core-city `Helpers\PdfPreviewWatermark`, propriété `previewWatermark` des 4 jobs) ; règle `App\Services\Documents\DocumentAccess` (abonnement payant, document déjà débloqué pour la parcelle dans `document_unlocks`, ou crédit d'un pack `DOC_*` consommé une fois par document et par parcelle), appliquée dans `ActionController` (`withDocumentPreview` : `document_preview` / `document_unlocked`), fenêtre `includes/_modal_document_offer` + `document-offer.js` (une fois par jour et par document), mention des prix dans la fenêtre d'accueil d'un particulier. Packs : `.insights/lot3-documents-2026-11-02/sql-packs-documents.sql` (4,90 / 9,90 / 9,90 / 14,90 € TTC). Interrupteur `DOCUMENT_TRIAL_PREVIEW` (faux par défaut ; ne l'activer qu'après les packs en production). ⚠ Exige le core corrigé (`HasPlan::currentPayment` : un pack faisait perdre l'essai). Banc 25/25 + A/B, détail et déploiement : `.insights/lot3-documents-2026-11-02/README.md`.
- **Téléphone, lot C de l'audit mobile (2026-11-01)** : compte rendu `/home/julio/www/.insights/mobile-audit-2026-11-01/VAGUE3-README.md` (section Lot C). (C3) `GET /.well-known/assetlinks.json` (`routes/web.php`, sans session ni cookie) lit `config/android_app.php` (`ANDROID_APP_SHA256_FINGERPRINTS`, empreintes Play Console, 404 sans elles) : l'application ouvre elle-même `/email/verify/`, `/reset-password/`, `/annonce/`, `/acquisition`, `/agenda`, `/paiement/`, `/compte/`. (C4) `parcel-tabs/common.js` `syncParcelNavScroll` : onglets de la fiche parcelle et sous-onglets de l'estimation en ligne défilante avec libellés, fondu `pcn-more-left` / `pcn-more-right`, onglet actif ramené dans la barre ; ⚠ `#parcelCardNav .btn-group-toggle` porte la classe Bootstrap `flex-wrap` (`!important`) : `flex-wrap: nowrap !important` sous 992 px. (C5) `filter-categories-advert.js` `advertFloatingBottomHidden` (pas de barre « Rechercher » collée en bas de la fiche annonce sous 768 px), `content-bar.js` (« / » seulement avec une souris), `mobile_nav.blade.php` (« Accueil » actif sur « / »), bouton de recherche à 40 px au doigt. `routes/console.php` : `ads:upload-conversions --purge` à 3 h 40 (C1, core). Et `users:erase-deleted` à 4 h 10 (2026-11-02, effacement des comptes supprimés depuis 30 jours, core). Bancs `c3_app.py` 13/13, `c4_tabs.py` 17/17, `c5_finitions.py` 9/9. Déploiement : `git pull` + `npm run prod` + `view:clear` + `route:clear`, puis l'empreinte dans le `.env` avant la publication de l'application.
- **Téléphone, vague 2 de l'audit mobile (2026-11-01)** : compte rendu et bancs `/home/julio/www/.insights/mobile-audit-2026-11-01/VAGUE2-README.md`. (8) Bandeau de fin d'essai `dashboard/_trial_banner.blade.php` en tête de `dashboard/index`, règles dans `App\Services\Premium\TrialBanner` : essai en cours (`TrialInfo`) = « Essai gratuit : encore N jours » (ton `info`, `warning` à 3 jours, `last` le dernier jour avec l'heure de fin), aucun forfait après un essai ou un abonnement = « Votre essai gratuit est terminé · Reprenez à partir de X € HT par mois » + « Choisir un forfait » (`#modalPlanPick`) ; prix = forfait mensuel le moins cher de `getUserPlan` avec `PlanOffer` (cache 1 h `trial_banner_price_v1_*`) ; l'essai refusé garde son encart ; bouton au style de « Vos premiers pas » (`btn btn-sm bg-to-light bg-to-text`, mixin `dash-soft-cta` partagé avec `.fs-step-cta`), bandeau sur fond blanc. « Voir la fiche » de la barre de recherche au même style. (17) `common.scss` : bloc `@media (hover: none)` (bouton « Éditer » des photos, actions de la frise, épingle / suppression, actions du kanban, recherches enregistrées, détail du score) ; `responsive.js` : un appui sur `.score-ring-wrapper` ouvre ou ferme son détail (`is-open`) en capture, sans l'action de la carte. ⚠ Chrome jetable = `hover: none` même en émulant une souris : vérifier l'ordinateur dans un vrai Chrome. (18) `responsive.js` `window.fitSuggestionLists` : sur écran tactile, `.kb-fit` (posée par `address-autocomplete.js`), `.map-search-dropdown`, `.cb-cad-dropdown`, `.pp-picker-list` bornées à la hauteur visible (`visualViewport` et `innerHeight`). (3, 9) dans le core (page « Vérifiez votre email », lien qui connecte, `app_platform`). Bancs `v2_*.py` 12/12, 10/10, 9/9, 17/17, 5/5, vraie application Android 5/5. Déploiement : après le tag core, `git pull` + `npm run prod` + `view:clear`.
- **Téléphone, vague 1 de l'audit mobile (2026-11-01)** : audit et tableau `/home/julio/www/.insights/brainstorm-mobile-tunnel-essai-2026-11-01.md`, compte rendu et bancs `/home/julio/www/.insights/mobile-audit-2026-11-01/VAGUE1-README.md`. Côté site : (1) `app.js` `updatePage` ne fait plus `showLeftMenu()` sans identifiant (TypeError, Retour sans effet sur téléphone), `window.showMenuSearch()` puis le switch ; « / » rendu par le serveur reçoit l'état `{page: 'dashboard'}` (`menu-map.js`, sinon un Retour vers lui faisait `hideLeftMenu()`, ordinateur compris). (7) `menu-map.js` : `Dashboard.init()` aussi sous 992 px sur « / ». (11) `responsive.js` `pushMobileMapHistory()` appelé par `showMap()` : étape « carte » (ancre `#carte`, état `mobile_map`) sur téléphone, jamais pendant un Retour (`window._historyNavigating`, posé par le `popstate` d'`app.js`) ni au démarrage ; le bouton Carte de la barre du bas revient par `history.back()` ; ancre retirée au rechargement et non recopiée dans les adresses des menus. ⚠ **Chrome n'ajoute aucune étape pour un `pushState` vers l'adresse exacte de la page** : d'où l'ancre. (6) `omnibar.js` : `window.focusAddressSearch(message)` (focus pendant le geste, utilisé par `first-steps.js` et `welcome-onboarding.js`) et `window.omnibarCardButton(codes)` appelé par `localise()` (cas `parcel`) : bouton `#cbOpenParcelCard` (« Voir la fiche » / « Fiche » sous 576 px) dans `.cb-input-wrap` seulement si la recherche porte sur une adresse ou une parcelle, masqué à la saisie, à l'effacement ou au changement de zone (observateur de `data-layer`) ; la fiche ne s'ouvre jamais d'office. (16) `acquisition.js` : sans choix mémorisé, vue liste sous 768 px (défaut non écrit dans `acquisition_view`) ; appui long du kanban 450 ms. (2) `common.scss` : `#modalPlanPick`, `#modalPlanLimit`, `#modalUpdateExceeding`, `#modalUpdateMean` à 3200 et voile à 3150 (`body:has(...)`) au-dessus de `#colCard`. (10) 14 feuilles : `100vh` suivi de `100dvh` (repli gardé), et `.modal .modal-dialog-centered::before` en `100dvh` (le pseudo-élément de Bootstrap gardait 874 px pour 810 visibles sur Chrome Android, mesuré sur l'émulateur). (5) `data-plan-target="<forfait>"` sur 12 boutons qui ouvrent la fenêtre des forfaits (teasers, fiches verrouillées, filtres, CRM, fiche lead, tableau de bord ; `plan_required_name`) : la fenêtre s'ouvre sur ce forfait sur téléphone (core). Bancs : `v1_nav.py` 6/6, `v1_plans.py` 19/19, `v1_leads.py` 7/7, `v1_checkout.py` 11/11, `v1_desktop.py` 5/5, vrai Chrome Android 1/1, vraie application 3/3. ⚠ Constaté, non traité : dans l'application, Retour juste après la connexion rouvre la page de connexion. Déploiement : `git pull` + `npm run prod` + `view:clear`, après le tag core.
- **Page de paiement v2 (2026-10-31, core), seule page de paiement** : page façon Stripe (`core::payment/checkout/*`), l'ancienne page et `PAYMENT_CHECKOUT_V2` supprimés, mêmes routes, PayPal supprimé, identité d'entreprise (SIREN, TVA) sur l'adresse et la facture, preuve du consentement sur le paiement, faille corrigée (carte ou adresse d'un autre compte acceptée par `agreement-execute*`). Changement d'abonnement réécrit (mensuel ↔ annuel, plus ou moins cher, comptes : échéance gardée et différence au prorata au même rythme, nouvelle période qui commence aujourd'hui vers un rythme plus long, période payée qui va à son terme vers un rythme plus court ; crédit = valeur payée), renouvellement qui n'est plus sauté après un paiement du mois, centimes arrondis. Rien à modifier dans le site : tag core + `composer update` + `migrate --path` depuis administration. Banc et déploiement : `.insights/checkout-v2-2026-10-31/README.md`.
- **Étape finale de la fenêtre d'accueil refaite et « Vos premiers pas » sans rechargement (2026-10-30)** : l'étape 3 de `#modalWelcomeOnboarding` mène à une première action au lieu d'un écran de lecture : « Votre espace est prêt », choix des étapes 1 et 2 en puces, fin d'essai (`App\Services\Premium\TrialInfo::endsAt`, essai application en cours), commune de travail avec ses chiffres (forfait sans Opportunités : `POST /premium/commune` avec `figures=1` → `PremiumPreviewController::welcomeFigures` : DPE < 90 j, annonces de la semaine `SectorAlert::recentAdvertCount`, annonces localisées, jamais une tuile à zéro, rappel de l'alerte E1 pendant l'essai ; événement `pp:commune-saved` de `premium-preview.js`), puis « Par quoi commencer ? » : 3 actions selon le profil (`data-wo-for` : pro, particulier, prestataire, agence créée ; `welcome-onboarding.js` `renderActions` / `runAction` : la fenêtre se ferme puis l'écran s'ouvre : recherche d'adresse, `/annonce?commune=`, Opportunités verrouillées `PremiumPreview.open(..., 'welcome')`, création de lead, `/compte/equipe/{id}`), aide en une ligne, bouton « Fermer ». « Vos premiers pas » : `resources/js/first-steps.js` écoute `ajaxComplete` (`POST /parcel`, `/acquisition/locateAdvert`, `/action/parcel/avis`, créations de lead) et relit le bloc par `GET /dashboard/first-steps` (`DashboardController::firstSteps` → `HasDashboardView::firstStepsBlock`, carte E4 sans journaliser de vue), une relecture par action, pas fait mis en évidence, « Bravo » puis retrait quand tout est fait. ⚠ `far` (Font Awesome regular) n'est pas chargé sur l'application : icônes en `fas`. Vérifié : `.insights/welcome-final-2026-10-30/browser.py` 51/51 ; bancs pastille 48/48, E4 34/34, premiers pas 22/22 + 12/12 réalignés. Déploiement : `git pull` + `npm run prod` + `view:clear` + `route:clear`. Détail : `.insights/welcome-final-2026-10-30/README.md`.
- **Fenêtre d'accueil sans l'étape Pipeline sans le forfait, pastille « Personnaliser mon pipeline » dans l'en-tête des leads (2026-10-30)** : sans `ACCESS_PIPELINE` (essai, Basique), l'étape 2 « Pipeline » de `#modalWelcomeOnboarding` est sautée (`data-pipeline-locked`, `welcome-onboarding.js` `planPipelineLocked` / `updateDots` : « Profil · C'est parti », étape 2 « Agence » ou « Invitation » seulement pour ces parcours) ; un compte avec le forfait garde le choix du modèle. Pastille `#acqPipelineHint` (`App\Services\PipelineHint`, `POST /acquisition/pipeline-hint/seen`) entre « Acquisition » et « Statistiques », vues kanban / liste / carnet seulement : avec le forfait, Paramètres › Tâches sans rechargement, masquée dès la première ouverture de l'onglet Tâches (`pipeline_settings_seen`, posé par `setting_list.blade.php`) ; sans, cadenas + forfait requis, fenêtre des forfaits avec la raison (`PremiumPreview.showPlanReason`), toujours affichée tant que le forfait n'inclut pas la fonction (décision du 2026-10-31). Libellé selon la largeur de l'en-tête (requête de conteneur `acqbar`). Vérifié : `.insights/pipeline-hint-2026-10-30/browser.py` 48/48. Déploiement : `git pull` + `npm run prod` + `view:clear` + `route:clear`. Détail : `.insights/pipeline-hint-2026-10-30/README.md`.
- **Alerte « nouvelles annonces de votre commune » pendant l'essai (2026-10-30, levier E1)** : commande `infoparcelle:sector-alert` (`app/Console/Commands/SendSectorAlerts.php`, déclarée dans `AppServiceProvider`), planifiée à 8 h dans `routes/console.php` ; règles et contenu dans `App\Services\Premium\SectorAlert` (config `config/sector_alert.php`, interrupteur `SECTOR_ALERT_ENABLED`). Destinataires : essai application en cours (projet 1, hors API), jour 1 à 6, non abonné, adresse confirmée, type « Fonctionnalités » par e-mail accepté (aucun choix = envoi), commune de travail connue (`HomeCommune` d'E4), au moins une nouvelle annonce de vente ; une alerte tous les 2 jours au plus et jamais le jour d'un e-mail de scénario (attente d'un scénario actif ou en clôture qui tombe ce jour-là) : J2, J4, J6 avec le scénario d'essai. Contenu : annonces de vente (maison, appartement, terrain, immeuble) depuis la dernière alerte ou l'essai (4 jours au plus), commune si 3 au moins, sinon intercommunalité (`fr_epci_maps`), sinon département ; 4 annonces (particuliers d'abord), bouton vers la liste, DPE de la commune (chiffres d'E4). E-mail `App\Mail\SectorAlertMail` (vues `mails/sector_alert` et `mails/sector_alert_text`, version texte écrite à part) envoyé **depuis le site, jamais en file**, par `SectorAlert::send()` : gestionnaire d'e-mails du core (`Rootsud\Core\Mail\Batch\MailManager`, instancié à part : le site enregistre celui de Laravel, qui perd les variables par destinataire donc `List-Unsubscribe`) et connexion par défaut passée sur `pgsql_root` le temps de l'envoi (⚠ `Rootsud\Core\Models\MailgunRecipient` est le seul modèle du core sans connexion explicite : « relation mailgun_recipients does not exist » depuis le site) ; gabarit du core chargé depuis le paquet (le site n'a pas `views/vendor/mail` publiées). Envois enregistrés dans le réglage `sector_alert_sent` (pas dans le journal d'activité : la carte Tunnel y compte les jours actifs). Liens suivis `GET /premium/alerte/{annonce|annonces|opportunites|commune}` (`SectorAlertController`, clic `sector_alert_click` une fois par minute, redirection interne seulement) ; `/annonce?commune=66136` (`LoadController::annonce`, aussi un département) ouvre la liste des annonces sur la commune ; `/?changer-commune=1` ouvre le choix de la commune du tableau de bord (`premium-preview.js`). Options : `--dry-run`, `--user=`, `--force`, `--test-to=` (alerte d'un compte envoyée à une adresse, rien enregistré ; simulation et test marchent alerte coupée), `--edition=standard|located`, `--commune=`. **Édition « annonces localisées »** (une fois par essai, à la place de la 2e alerte) : opportunités `Advert_locate_score` actives de la commune (sinon intercommunalité, département), les 4 mieux notées avec « Score 97/100 » et « Parcelle identifiée · visible avec le forfait Premium », jamais la parcelle, bouton « Découvrir le menu Opportunités » (`SectorAlert::locatedContent`, `locatedEditionDue`, réglage `located_at`, liens `e=located` notés dans le clic). Photos vérifiées avant l'envoi (`SectorAlert::photoReachable`, cache 12 h ; ⚠ pas d'en-tête `Range` : img.leboncoin.fr répond 400) ; annonce sans photo = image par défaut `resources/images/email/annonce-sans-photo.png` intégrée en `cid:` seulement si besoin ; case image de taille fixe. `lang/fr.json` : « All rights reserved. » traduit (pied du gabarit du core). Vérifié : `.insights/e1-alerte-commune-2026-10-30/` (`harness.php` 64/64, `e2e.sh` 19/19, `browser.py` 14/14), les deux éditions envoyées à l'adresse autorisée du domaine de test, envoi réel jusqu'à Mailgun (403 du domaine de test en dev), PHPUnit 160 / 3 préexistants. Mesure : `sql-mesure-e1-prod.sql` + carte Tunnel. Déploiement (avec ou après E4) : `git pull` + `npm run prod` + `view:clear` + `route:clear`, clés Mailgun du site identiques à celles de l'administration, simulation puis test vers mail-tester, puis activation. Détail : `.insights/e1-alerte-commune-2026-10-30/README.md`.
- **Valeur Premium avec les chiffres de la commune (2026-10-30, levier E4)** : un compte sans `ACCESS_OPPORTUNITY` (essai, Basique) voit les chiffres réels de sa commune au lieu d'un écran de présentation. Services `App\Services\Premium\HomeCommune` (commune de référence : réglage `home_commune`, sinon 20 dernières traces `stats_*`, sinon premier secteur ; `describe()` : département, commune, déléguée ramenée à sa tête, villes à arrondissements = leurs arrondissements ; `atName()` « au Havre »), `SectorSnapshot` (cache 6 h `e4_snapshot_v2_*` : signaux = opportunités actives de la zone - DPE < 90 j et < 30 j, annonces localisées, terrains PLU - par plage `cadastre_id`, signaux du département si la commune a moins de 3 DPE récents, prix = `fr_zone_price_cache` DVF « 3y » avec mois de départ affiché (la fenêtre « 1y » ne couvre que ~3,5 mois, DVF arrêté au 31/12/2025), évolution de la ligne « 1y », repli département puis annonces ; un exemple réel sans la parcelle), `PremiumPreview` (mesure `premium_preview_view` une fois par compte, fonction, écran et jour, `premium_preview_click` ; `cta()` = changement de forfait avec l'écart réel `PlanOffer`, fenêtre des forfaits pour un essai, contact sans forfait cible). `PremiumPreviewController` + routes `GET /premium/apercu/{opportunity|market|dashboard}` (`?insee=`, `?save=1`), `GET /premium/communes` (nom ou code postal), `POST /premium/commune` (422 commune inconnue), `POST /premium/clic` (204). Vues `premium/_local_opportunity`, `_local_market`, `_branding_preview`, `_cta`, `_commune_picker`, `dashboard/_premium_snapshot`, `parcel/_premium_signals_line` (fiche simple et multiparcelle, fermable pour la journée, localStorage `ip_pp_parcel_hidden_{id}`) ; écrans verrouillés `opportunity`, `market`, `setting/tabs/style` (le bouton passe sous le bloc local) ; « Vos premiers pas » avec chiffres et ligne « Documents à vos couleurs » ; étape 3 de la fenêtre d'accueil : commune de travail facultative ; `ActionController::withBrandingHint()` sur les 6 générations de PDF (une fois par jour). JS `premium-preview.js` (`window.PremiumPreview` : `onLockedScreen`, `fromSearchBar`, `open`, `showBrandingHint`) : ⚠ **`window.toastr` n'existe pas sur l'application**, seul `window.toast` du core (sans options) : le lien du rappel est ajouté au dernier `.toast-info`. Les écrans verrouillés ne lancent plus `PriceMap.init()` ni la recherche d'opportunités (`menu-map.js`, `opportunity.js`, `setting-agency.js`) : fin du 432 qui ouvrait la grille des forfaits d'office. Carte des prix réservée côté serveur par core-city (cf. son CLAUDE.md). Mesure : `.insights/e4-valeur-verrouillee-2026-10-30/sql-mesure-e4-prod.sql` + « dont Premium ou CRM » de la carte Tunnel (administration). Vérifié : `harness.php` 23/23, `e2e.sh` 33/33 (Basique, CRM, essai), `browser.py` 34/34 (Chrome headless, 390 px, non-régression CRM, 0 erreur JS), PHPUnit 160 / 3 préexistants. Déploiement : tag core-city + `composer update` (site, administration), puis `git pull` + `npm run prod` + `view:clear` + `route:clear`. Détail : `.insights/e4-valeur-verrouillee-2026-10-30/README.md`. Retouches du 30/10 au soir : la liste du choix de commune (`.pp-picker-list`) est dans le flux et non plus flottante (elle était coupée par la carte `.pp-dash` du tableau de bord ; le bloc s'agrandit le temps du choix, mesuré à 1440 et 390 px) ; dans « Vos premiers pas », « Voir comment faire » de l'avis de valeur ouvre le guide `estimation/estimation-et-avis-de-valeur` (le guide `avis-de-valeur-pdf` décrit les pages du PDF, pas comment le créer), banc `.insights/first-steps-2026-10-26/server.sh` 12/12 (une assertion supposait l'ancien essai de dev, aligné sur la production le 29/10).
- **🟥 Recherche de lead de la fenêtre de rendez-vous en 500 depuis l'origine (2026-10-30)** : erreur de production fréquente `42703 column "cadastre_id" does not exist` sur `GET /acquisition/leads/search` (`AgendaController::searchLeads`). La requête filtrait sur `leads.cadastre_id`, qui n'est PAS une colonne : c'est un accesseur PHP de `Lead` calculé depuis `multiparcel_id` (`$lead->cadastre_id` fonctionne, d'où la confusion). Toute saisie de 2 caractères ou plus dans le champ « Lead » de la fenêtre de rendez-vous répondait 500 depuis le 2026-03-22, et le `.fail(function() {})` de `agenda.js` avalait l'erreur : la liste restait figée sur « Récents ». Livré : la recherche réutilise celle de la liste et du kanban (`HasLead::applyLeadSearch` par l'instance anonyme `leadSearchHelper()` : titre, adresse, contacts du dossier, étiquettes, zones, référence cadastrale de 14 caractères) plus les contacts des anciennes colonnes `contact_id` / `contact_seller_id` / `contact_buyer_id` ; nom affiché = contact principal du dossier, sinon ancien contact, sinon `display_name` ; côté écran, nom, adresse et étape échappés (ils viennent aussi des widgets publics et étaient insérés tels quels en HTML) et message « Recherche indisponible, réessayez dans un instant » en cas d'échec. Vérifié : `.insights/fixes-2026-10-30/agenda-lead-search.sh` (ancien code : 4/4 en 500 ; corrigé : 11/11, dont contact du dossier seul, contact des anciennes colonnes seul, référence cadastrale, titre, saisie avec apostrophe), navigateur (frappe réelle, sélection, échec forcé en 404, nom piégé affiché en texte), PHPUnit 160 / 3 préexistants. **Suite le même jour : recherche des participants (`searchAttendees`, `GET /agenda/attendee-search`)** : même `.fail` muet et mêmes noms / e-mails insérés tels quels, dans les résultats ET dans les puces (`renderAttendeeChips`, aussi rendues à la réouverture d'un rendez-vous) : échappés, message en cas d'échec. Les deux recherches traitent aussi comme un échec une réponse qui n'est pas une liste (une URL inconnue sous `/agenda/` tombe sur la route générique `/agenda/{map?}` et rend la page HTML en 200 : `data.filter is not a function`). Vérifié en navigateur : recherche et sélection réelles, 404 et page HTML forcés sur les deux recherches (message affiché, 0 erreur JS), nom piégé en texte dans les résultats et la puce, retrait de la puce. ⚠ Constaté, non traité : `GET /acquisition/<texte non numérique>` répond 500 (`22P02`, l'identifiant de lead est passé tel quel à la requête). Déploiement : `git pull` + `npm run prod`.
- **Saisies décimales vers colonnes entières (2026-10-29)** : 500 (22P02) en production à l'enregistrement d'une estimation et de l'avis de valeur (surface du séjour « 19,80 »), reproduits aussi pour la surface habitable d'un lead (création et « Modifier le projet »), la surface et le prix au m² d'une estimation. Correctif dans core-city-user (trait `NormalizesNumericColumns`, migration `surface_sejour` / `surface_balcon` en décimal, cf. son CLAUDE.md) et dans le core (capital de la fiche légale). Côté site : `ActionController::saveEstimationData` cherche l'estimation existante par la valeur que le modèle enregistre (`Estimation::numericValueFor('surface', ...)`, repli arrondi si le package n'est pas à jour), sinon `where('surface', '120.5')` échoue sur une colonne entière ou rate l'estimation existante. **Suite le même jour : surfaces conservées au décimal** (`leads.surface`, `estimations.surface` en numeric(10,2) par core-city-user) : `FeeController` n'arrondit plus la surface de l'estimation ; affichage par `surface_fr()` (copie de repli dans `app/Helpers/bati_detail_helpers.php`) dans la fiche lead (contexte du bien, instantané, détails, KPI, onglet Estimation), le kanban, la liste, les biens compatibles, le matching, la fiche contact et les biens proposés du portail ; `{{lease.surface}}` et `{{estimation_surface}}` des documents en « 98,5 » ; panneau honoraires de location (`acquisition.js`, `#ldepSurfaceVal`) au format français. Bancs : `.insights/fixes-2026-10-29/` (`estimation-surface-decimal.sh`, `decimal-inputs-other-fields.sh`, `legal-capital-decimal.sh`, `surface-decimal-display.sh` 10/10, `normalize-numeric-columns.php` 31/31). Déploiement : après les tags core-city-user + core et `migrate`, `git pull` + `npm run prod` + `view:clear`.
- **Tableau de bord « Vos premiers pas » et accueil sans forfait équipe (2026-10-26, audit du tunnel D1 à D3)** : `HasDashboardView::getFirstStepsData()` (compte de moins de 30 jours avec un forfait) → `dashboard/_first_steps.blade.php` : pas réalisables avec le forfait, cochés d'après les traces réelles (`core.activity_log` `stats_parcel_detail` / `estimate` « Generation avis de valeur », `advert_locations`, `leads`), puis modules verrouillés visibles avec le forfait qui les débloque (nouveau helper **`plan_required_name($variable, $fallback)`** dans `plan_access_helpers.php`, cache 1 h par compte et variable) ; « Masquer » = localStorage `ip_first_steps_hidden_{id}`. `_kpi_summary` : le bloc d'accueil sans lead cède la place aux premiers pas, les pastilles widgets / opportunités / annonces portent `kpi-lock-tag` et ouvrent la grille quand le module est hors forfait ; `_top_leads` vide propose « Nouveau lead ». Fenêtre d'accueil : étiquette du forfait sur « Créer une agence », encart `#woAgencyLocked` (`data-team-locked` sur `#woAgencyWrap`) avec « Continuer seul » (profil `independant`, rappel « Votre agence : À créer avec le forfait … » au récapitulatif) et « Voir le forfait … » (`openPlans`) ; textes du profil (D3). ⚠ **Le `$` du bundle ET `window.jQuery` n'ont pas le plugin Bootstrap** : `.modal()` et les événements `shown/hidden.bs.modal` ne sont accessibles que par les fonctions du core (`showModal`, `hideModal`, `resetModal`) ; et `hideModal` ne fait rien pendant la transition d'ouverture (d'où les nouvelles tentatives d'`openPlans`) ; `resetModal` laisse le gestionnaire de focus de Bootstrap sur une fenêtre détruite (erreur JS). Vérifié : `.insights/first-steps-2026-10-26/server.sh` 11/11, `browser.py` 22/22 (Chrome headless, traqueurs bloqués, captures ordinateur et 390 px), PHPUnit 160 / 3 préexistants. Déploiement : `git pull` + `npm run prod` + `view:clear`.
- **Essai refusé à l'inscription : encart « Demander l'activation de mon essai » (2026-10-26, audit du tunnel B7)** : `includes/_trial_refused_notice.blade.php`, inclus sous la carte d'accueil du tableau de bord, s'affiche quand le réglage `trial_refused` (posé par le core, `ProjectSiteTrait::recordTrialRefusal`, refus par un contrôle anti-abus) existe, sans forfait actif (`plan_access('ACCESS_LEAD')` faux) et sans aucun essai ni abonnement passé. Bouton vers `https://www.infoparcelle.fr/contact?sujet=activation-essai` (sujet et message présélectionnés par la vitrine) + « Voir les forfaits ». La règle en cause n'est jamais citée. ⚠ Le site se résout comme `dashboard/_welcome` (`$site` de la vue d'abord) : `app('Rootsud\Core\Models\Project_site')` seul rendait un site vide dans le fragment POST du tableau de bord (encart jamais affiché, trouvé par le test). Styles `.trial-refused-notice` dans `dashboard.scss`. Vérifié : `rs_infoparcelle_vitrine/.insights/tunnel-b7-b12-2026-10-26.sh` 24/24, rendu ordinateur et 390 px. Déploiement : `git pull` + `npm run prod` + `view:clear`, après le tag core.
- **Onglet Textes IA de l'estimation (`#tab-textes-ia`) : de nouveau fonctionnel, et un échec n'efface plus rien (2026-10-26)** : les textes « Présentation du bien » et « Analyse de l'estimation » (bouton « Générer » et mode automatique à la création du PDF) passaient par l'Assistants API d'OpenAI, arrêtée le 26/08/2026 (404) : 100 % de textes vides depuis le 02/09, et pire, `EstimationController::generateEditText` répondait **200 après ~26 s** avec un texte vide, l'**enregistrait à la place du texte existant** (parcel_user_details + empreinte + Estimation_text) et l'écran affichait « Texte généré avec succès » en vidant la zone (A/B legacy `e2e.sh` 7/7 : défaut reproduit). Rédaction désormais par core-city `Helpers\AvisValeurTextWriter` sur core-ai `Services\OpenAiTextClient` (API Responses, modèle `gpt-6-sol`, ~5 s, consignes dans le code, rien d'inventé). `generateEditText` appelle `getResume($data, true, true)` / `getResumeEstimation($data, true)` et, en échec, répond **422 `{error: ai_text_failed, message}`** sans rien écrire (⚠ jamais 503 : `bootstrap.js` redirige vers l'accueil sur 503) ; `tab-estim-ia.js` traite une réponse 200 vide comme une erreur (cas d'un site déployé avant les packages) et garde le texte. Vérifié : client `harness-client.php` 32/32 (`Http::fake`), E2E réel `e2e.sh` 11/11 (deux types, texte enregistré = texte renvoyé), échec réel (modèle inexistant) 422 + témoin conservé, PDF par le worker `pdf-e2e.sh` 8/8 (compte 214307 : le compte 2 a 3 appareils Expo, une génération d'avis y pousse sur les téléphones), navigateur (génération, échec 422, réponse vide : texte conservé, 0 erreur JS), PHPUnit 160 / 3 préexistants. Harness : `.insights/ai-texts-responses-2026-10-26/`. ⚠ Constaté, non traité : une parcelle porte le point positif « Année de construction 1800 » (donnée par défaut probable), que le texte reprend. Déploiement : tags core-ai + core-city + core + `composer update` (site, administration ; vitrine pour core + core-ai) + `queue:restart`, puis site `git pull` + `npm run prod` + `view:clear`. Aucune reprise de données : en mode automatique, un texte vide est rédigé à la prochaine création du PDF. **Suite (retour utilisateur) : l'analyse est rédigée comme le professionnel qui signe « L'avis de votre expert »** (« nous » / « je », recommandation, jamais « estimation indicative à confirmer par une visite ») ; `computeTextHash('estimate')` inclut désormais les points clés (`swp_*`) : ⚠ toutes les analyses existantes en mode automatique deviennent « à rafraîchir » et sont réécrites, dans la nouvelle voix, à la prochaine création de leur PDF (`ActionController` efface un texte périmé avant le job) ; une analyse en mode manuel n'est jamais touchée. Harness client porté à 42/42.
- **Matching sur le portefeuille de l'agence + avatar du collègue (2026-10-08)** : un dossier d'agence (`leads.group_id` = agence active, propriétaire membre vérifié, rôle autre que `viewer`) rapproche désormais les leads de TOUTE l'agence (leads `group_id = agence`), plus seulement ceux du propriétaire. Règle unique `Lead::matchingAgencyId($viewer)` + scope `matchingPortfolio($ownerId, $agencyId)` (core-city-user, cf. son CLAUDE.md), consommés par `leadMatches`, `compatibleLeads`, la garde de `proposeMatch` (lead du portefeuille du propriétaire OU accessible OU de l'agence ; tiers toujours 403), la carte des correspondances, `HandlesMatchSelection` (aperçu / envoi / PDF) et le KPI « Meilleur match ». Les leads perso d'un collègue (hors agence) ne sont jamais proposés ; un membre `viewer` et un compte hors agence gardent le portefeuille du propriétaire. `AcquisitionController::decorateMatchOwners()` (2 requêtes) pose `_owner_brief` (`lead_user_brief`) et `_viewer_can_open` (`Lead::sharedFor`) : chaque carte d'un lead dont le viewer n'est pas propriétaire porte l'avatar du collègue (`_shared_by_avatar`, classe `lead-owner-avatar`, anneau bleu, nom en infobulle) dans `_match_lead_card`, `_match_portfolio_card` et `_compatible_leads_list` ; un dossier non partagé masque « Voir le lead », le bouton Visite, « Présenter le bien » et le téléphone, remplacés par « Acquéreur / Bien suivi par X. ». « Présenter ce bien » liste l'acquéreur d'un collègue sans jamais le contacter (`followed_by_colleague`). Sous-titres (`lead_matching_scope()` : `label` / `label_de`) : « Biens du portefeuille de l'Agence X et annonces du marché... », section « Portefeuille de l'agence ». ⚠ `matching_score_meta` (KPI) raisonne du point de vue du propriétaire. Vérifié : A/B `.insights/matching-agency-portfolio-2026-10-08.sh` (legacy 20/20 défaut reproduit, fixed 31/31), E2E HTTP `.insights/matching-agency-portfolio-e2e-2026-10-08.sh` **30/30** (vraies routes, session réelle, dossiers de Thomas non partagés fabriqués puis supprimés, compte hors agence 214295), rendu navigateur `.insights/matching-agency-portfolio-2026-10-08/browser.py` 8/8 (avatars visibles, 0 erreur JS). Coût : onglet acquéreur 19 → 25 requêtes, vendeur 17 → 22, fenêtre d'ajout 15 → 19. Déploiement : tag core-city-user + `composer update` (site ET administration) + `queue:restart` + `npm run prod` + `view:clear`.
- **Bon de visite : « Modifier le CR » du rendez-vous lié ouvre le compte rendu structuré (2026-10-08)** : dans la carte d'un bon (`_lead_bon_visite.blade.php`, bloc du RDV lié), le bouton `.leadEventReport` n'avait ni `data-event-type-slug="visite"` ni `data-lead-id` : le clic ne faisait rien (aucune modale, mesuré en navigateur), alors que les autres boutons du même type ouvrent `#modalCrVisite`. Attributs ajoutés (le RDV d'un bon est toujours une visite). A/B navigateur : avant 0 modale, après `#modalCrVisite` ouverte et préremplie. `view:clear`.
- **Civilité « M. » au lieu de « Mr » (2026-10-08)** : `Contact::civility_text` (core-city-user) rend « M. » / « Mme », et une civilité inconnue (null) ne rend plus « Mr » ; touche `fullname`, donc les parties des baux et compromis, `dest_name`, `client_fullname` et les écrans ; `contact_result_item` lit l'accesseur. Aucun modèle ne dépend du texte « Mr » (vérifié en base locale et préprod). Harness `.insights/civility-2026-10-08/harness.php` (legacy 1/5, fixed 5/5).
- **Modèles système de l'ancienne génération en préprod (2026-10-08)** : `.insights/sql-modeles-systeme-emdash-doublons-2026-10-08.sql` (rejouable) archive les doublons prouvés sans document lié, garde actifs ceux qui ont un document (#5, #8 : suffixe « (ancienne version) », équivalent `is_default`), retire les « — » des modèles système actifs sans toucher `updated_at`. Préprod : 37 archivés, CR de visite par défaut #74 (au lieu de #8). Restent actifs sans équivalent prouvé : #12, #13, #14, #20, #31, #34.
- **Centre d'aide prive `/aide` (2026-10-07, phase 2 du plan `.insights/brainstorm-formation-modules-2026-10-06.md`)** : `App\Services\Help\GuideRepository` lit `resources/guides/<categorie>/<slug>.md` (en-tete YAML : `titre`, `resume`, `categorie`, `ordre`, `icone`, `duree`, `niveau`, `forfait`, `modes`, `lien_module`, `mots_cles`, `verifie_le`), categories dans `GuideRepository::CATEGORIES` (`demarrer`, `carte`, `annonces`, `estimation`, `crm`, `agenda`, `widgets`, `agence`, `abonnement`, `integrations`), fichiers `_*.md` jamais publies, catalogue en cache sous une empreinte (chemin + date + taille) : rien a lancer apres l'ajout d'un guide. `GuideRenderer` : CommonMark + GFM + HeadingPermalink (ids `Str::slug`), `html_input=strip`, post-traitement DOM (encadres `> [!ASTUCE|IMPORTANT|FORFAIT]`, images reecrites vers `/aide/media/...` avec dimensions + figure zoomable + legende = texte alternatif, liens `/aide/cat/slug` inexistants = `span.help-link-pending`, tableaux defilants, `<h3>` de « Questions frequentes » ou finissant par « ? » en `<details>`, `ol` de premier niveau = etapes). `GuideSearch` : index `/aide/index.json` (titre, resume, mots-cles, intertitres + questions, termes en gras, VOCABULAIRE complet du guide), notation (poids t10 k8 h5 s5 r3 c2 b1, mot exact / debut / meme racine >= 6 lettres / une faute, couverture au carre, bonus si un intertitre reunit 2 mots) **reproduite a l'identique dans `resources/js/help-center.js`** : toute modification se fait des deux cotes, `search-check.sh` le verifie. `HelpController` : page pleine largeur du layout application (`core::includes/base` + `template` + `vendor`, comme /compte), `X-Robots-Tag: noindex`, public du lecteur (`agent|mandataire|agence|investisseur|particulier|prestataire|notaire`, via agence courante + `PipelinePresetService::resolveProMode` + metier) pour ordonner les listes, forfait requis resolu par `BuildsPlanTeaser::planTeaserData` (repli statique). Routes (groupe auth, avant les catch-all) : `/aide`, `/aide/index.json`, `/aide/recherche`, `POST /aide/recherche/vide` (journalise `[aide] recherche sans resultat`), `/aide/media/{path}` (realpath borne a `resources/guides/img`, extensions blanches), `/aide/{categorie}`, `/aide/{categorie}/{slug}`. Entrees (2026-10-24, menu Aide et tutoriel supprimes) : lien direct « Centre d'aide » en bas de la barre laterale (`#sidebarHelpCenterLink`, `template.blade.php`) et bouton de la carte d'accueil du tableau de bord (`#welcomeHelpCenterBtn`) ; Contact / Signaler un bug / Proposer une idee dans l'en-tete de chaque page du centre d'aide (`help/_support_links.blade.php`) et dans son pied, `target=_blank` **sans `rel=noopener`** (le bouton « Essayer maintenant » `[data-hc-open-app]` pilote l'onglet de l'application via `window.opener`). ⚠ La visionneuse est deplacee au niveau du `<body>` (sinon la barre laterale passe au-dessus du voile). ⚠ Ecrire les echappements `\u0300` d'une regex JS a la main dans le fichier : l'outil d'ecriture les a convertis en caracteres combinants. Recette : `.insights/help-center-2026-10-07/` (`crawl.sh [user]`, `search-check.sh` + `queries.json`, `interactions.py`, `entries.py`). **Chapitres et vidéos (2026-10-24)** : catégorie `parametrage` (« Paramétrage », après `demarrer`) = chapitre qui suit l'ordre des onglets de Paramètres et de Mon compte ; en-tête `aussi_dans: { parametrage: N }` = un guide d'une autre catégorie figure aussi dans le chapitre à la position N (`GuideRepository::byCategory` le fusionne avec `ordre` = N et `rubrique_hote`, `isChapter()`, `chapterNeighbors()` ; le pager d'un guide du chapitre suit l'ordre du chapitre, un guide rangé ailleurs garde son pager et affiche en plus la pastille « Chapitre … · N/T » et le bloc « suite du chapitre ») ; en-tête `onglet: "Paramètres › Tâches"` affiché sur la carte et sous le titre ; en-tête `video` (`src` https ou `img/...`, `poster` sous `img/`, `titre`, `duree`, `enregistree_le`, `note`, `chapitres: [{t: "1:03", titre}]`) rendu par `help/_video.blade.php` sous le titre du guide et en tête de la catégorie qui le désigne (`CATEGORIES[...]['video_guide']`) ; `preload="none"`, chapitres cliquables (`help-center.js` `setupVideo` : charge les métadonnées puis déplace la lecture, surligne le chapitre en cours) ; deux vidéos : paramétrage 27 min (`parametrage/parametrer-son-compte`) et widgets 15 min (`widgets/creer-et-diffuser`), hébergées sur public.rootsud.fr, aperçus `img/parametrage/video-parametrage.webp` et `img/widgets/video-widgets.webp` (l'image d'aperçu de production est blanche). Ancienne adresse `demarrer/parametrer-son-compte` redirigée en 301 (`HelpController::MOVED`). **Lecture dans le chapitre et jargon du métier (2026-10-26)** : un guide repris par `aussi_dans` a aussi une adresse dans le chapitre (`/aide/parametrage/mode-professionnel`) : `GuideRepository::inCategory()` (guide de la catégorie, sinon guide repris, `slug` unique exigé), `byCategory()` pose `url` = adresse dans le chapitre et `url_origine`, `chapterUrls()` ; `HelpController::show` lit la catégorie de l'URL comme contexte (`$context`) : dans un chapitre, fil d'Ariane, groupe ouvert et guide actif de la barre, guides précédent / suivant et liens du texte (`linksInChapter`, après le cache du rendu) restent dans le chapitre ; à sa propre adresse, le guide garde son parcours, la pastille et la suite du chapitre (dont les liens mènent au chapitre). `exists()` accepte l'adresse dans le chapitre. Recherche (serveur ET `help-center.js`, mêmes règles) : `INVARIABLE` (« plus » ne devient plus « plu », d'où « PLU » qui ramenait les guides écrivant « plus »), `COMPOUNDS` (« carte T » → un mot), `APPROX_WHEN_EXACT_EXISTS` 0,3 pour les mots de 4 lettres au plus présents tels quels dans un guide (« PAP » ne ramène plus « papier », « FAI » plus « faites » ; les conjugaisons des mots longs gardent leur tolérance : la règle appliquée à tous les mots faisait perdre 3 requêtes), sigle en majuscules jamais mot vide (« CES »), `INDEX_VERSION` 4. Jargon : terme en gras + sens entre parenthèses dans le guide de l'outil + `mots_cles`, section « Le jargon des professionnels » du glossaire (18 termes), règle dans `_charte.md` ; `lint-guides.py` les vérifie. Vérifié : recherche 380/380 serveur et navigateur (31 requêtes ajoutées : pige, PAP, off market, ACM, FAI, carte T, CES...), check-guide 13/13, chapitre parcouru de 1 à 18 sans en sortir, parcours complet 86 pages / 99 liens / 274 médias / 0 échec (990001 et 990004), PHPUnit 160 / 3 préexistants, lisibilité inchangée (38 / 0 / 0). Déploiement : `git pull` + `npm run prod` + `view:clear`.
- **Mesure du centre d'aide et « Ce guide vous a-t-il aidé ? » (2026-10-29, phase 8 du plan)** : 1 migration de STRUCTURE (`2026_10_29_000001_create_help_center_measures`, schéma du site, 3 tables sans clé étrangère vers core.users) : `help_guide_stats` (une ligne par compte, guide et jour : `views`, `video_plays`, `try_clicks`, unique `user_id, guide, day`, incrément par `insert ... on conflict`), `help_guide_feedback` (un avis par compte et par guide : `helpful`, `reason` parmi `HelpMetrics::REASONS`, `comment` 1 000 caractères, `guide_verified_on` = `verifie_le` au moment de l'avis, `context`), `help_searches` (sans résultat : `results = 0` ; guide ouvert depuis les résultats : `guide`, `source` instantanee | page ; `query_key` = minuscules sans accents ni ponctuation ; même recherche du même compte ignorée 2 min). Service `App\Services\Help\HelpMetrics` (`recordView`, `recordEvent`, `vote`, `userVote`, `recordSearch`, `report`, `purge`, `isAdmin`) ; **toute mesure passe par `safely()`** : table absente (git pull avant migrate) ou base indisponible = page servie + un WARNING `[aide] mesure non enregistrée`. `HelpController` : `show()` compte la consultation sous la clé d'ORIGINE (guide lu dans un chapitre = son guide) et lit l'avis (`rateState` : ask | why | done ; avis antérieur au `verifie_le` actuel = question reposée) ; `POST /aide/avis` (`feedback`, throttle 30/min, JSON pour le navigateur, sinon 302 vers `/aide/{contexte}/{slug}#hcRate` + flash `hc_rate_why` après « Non » ; « Non » sans `details` garde motif et commentaire déjà saisis) ; `POST /aide/mesure` (`track` : `video` | `essai` | `recherche`, référence de guide par clé ou par adresse, envoyé par `navigator.sendBeacon` avec `_token`) ; `GET /aide/statistiques` (`stats`, 404 hors `config('help_center.admin_user_ids')` = **`HELP_CENTER_ADMIN_IDS`**, déclarée AVANT `/aide/{category}`) ; `searchMiss` et la page `/aide/recherche` vide écrivent aussi en base (le journal `[aide] recherche sans resultat` est conservé, source `instantanee` | `page`). Vues : `help/_rate.blade.php` (sous le corps du guide, formulaires utilisables sans JavaScript), `help/stats.blade.php` (période 30 / 90 / 365 jours / tout, 6 indicateurs, tous les guides du catalogue avec tri `HelpMetrics::SORTS`, clés disparues à part, avis négatifs avec auteur, e-mail et « avant la mise à jour du guide », recherches sans résultat, recherches abouties), lien « Mesure du centre d'aide » dans le pied pour l'équipe (`$isHelpAdmin`, posé par `page()`), `data-hc-guide` sur l'article, `data-hc-video-guide` sur la vidéo (catégorie : clé du guide qui la porte), `data-hc-search-page` sur la page de résultats. `help-center.js` : `setupRate` (Oui = remerciement ; Non = avis enregistré puis motif, focus sur le premier motif ; Passer ; Modifier mon avis), `track` (beacon, repli `fetch keepalive`), vidéo comptée une fois par page (événement `play`, avant la garde des chapitres), « Essayer maintenant » depuis l'article, clics et clic molette sur les résultats instantanés et sur la page de résultats. Styles `.hc-rate-*`, `.hc-stats-*`. Purge : `help:purge-measures` (`--dry-run`, `--months`, conservation `help_center.retention_months` = 25 mois, avis conservés), dimanche 2 h 30 (`routes/console.php`). Charte : section 12 « Mesurer et améliorer ». ⚠ En dev, le compte 2 lit réellement le centre d'aide : un banc compare des ÉCARTS à une référence, et ne supprime que les mesures de ses propres comptes. Vérifié : service `.insights/help-center-2026-10-29/metrics-harness.php` 33/33 (transaction annulée), bout en bout `e2e.py` 51/51 (990001 lecteur, 214307 équipe : avis, motif conservé, 422 / 404 / 419, sans JavaScript, guide mis à jour, événements, recherches, page de mesure et ses 5 tris), navigateur `browser.py` 23/23 (Chrome headless jetable, clics réels, 390 px, 0 erreur JavaScript), page servie sans les tables (200 + WARNING), migration rejouée et `down()` puis `up()` ×2, recette interactive existante 25/25. Déploiement : `git pull` + `php artisan migrate` + `npm run prod` + `view:clear` + `route:clear` + **`HELP_CENTER_ADMIN_IDS=<ids de l'équipe>` dans le `.env`** (vide = page de mesure introuvable pour tout le monde).
- **Top 10 de l'onglet Annonces de la fiche parcelle : banc inverse (2026-09-29, rien activé)** : scoring de `TabAdvertController::matching` extrait dans `App\Services\AdvertLocate\ParcelAdvertMatcher` (source unique, variantes de banc via le 3e argument du constructeur, sortie de production identique au md5) ; `advert:parcel-benchmark` mesure le rang de l'annonce vraie (`rank`) et de l'annonce ou de son doublon (`rank_eq`, `isDuplicateListing`). Référence gold 49,8 % / 78,1 %. **ACTIVÉ le 2026-09-30** : `dpe_date_surf` + `exact_land` via `config('advert_locate.parcel_tab_variants')` (surcharge `ADVERT_PARCEL_TAB_VARIANTS`) → gold 58,4 % / 83,8 %, score affiché plafonné à 100. **2e passe activée le même jour** : + `land_neighbor`, `dpe_letter_any`, `wide_l3`, `class_fix` (le score lisait toujours « E » / « G » dans `ENERGY_CLASSIFICATION_x`) → gold 58,2 % / 84,6 %, platine 94,2 % (s. 10.29). Détail : `.insights/advert-locate-benchmark/README.md` s. 10.28.
- **Top 10 de la fiche annonce : terrain exact, date DPE sans étiquette et fusion du top 10 ACTIVÉS (2026-09-29, accord utilisateur)** : `getFilters` (search.js) pose `parcel_surface_exact_bonus` et `energie_dpe_date_letters` ; `launchQuickParcelsSearch` (advert_card.js) lance une 2e recherche sans `parcel_surface_min/max` et compose le top 10 = 9 premières places + recherche sans borne + suite (miroir `LocateReplay::fuseWithoutSurfaceBounds`, variantes d'A/B `no_exact_land_bonus` / `no_dpe_date_letters` / `no_top10_fusion`). Banc `act1` vs `ex1` : gold top 1 105 → 112, top 10 136 → 141 ; silver 190 → 194 / 214 → 260 ; DVF 509 → 547 / 663 → 678 ; total top 1 63,9 → 67,8 %, top 10 80,5 → 85,8 %. ⚠ 2e requête par fiche. Déploiement : tag core-city + `composer update` (site ET administration) + `config:clear` + `npm run prod` + `view:clear`. Détail README du banc s. 10.21.
- **Top 10 : marqueurs de carte des parcelles issues de la fusion (2026-09-29)** : la carte ne dessinait que la recherche principale (limite 10), donc les parcelles du top 10 venues de la recherche sans borne de surface n'avaient pas de marqueur. `fetchMapGeojson` (advert_card.js) interroge aussi la recherche sans borne quand il y a fusion, puis garde exactement les codes affichés dans le bloc (dédoublonnés). Sans fusion, réponse principale inchangée. Vérifié sur l'annonce 219346929 : 11 cartes, 11 marqueurs dont AT0193 (absent avant). Déploiement : `npm run prod`.
- **Top 10 : promotion DPE ACTIVÉE (2026-09-29)** : dans la fusion du top 10, si la tête n'a pas la date DPE de l'annonce, la 1re parcelle du top 10 avec date DPE pleine + étiquettes DPE/GES concordantes + points de surface habitable passe en tête (`LocateReplay::fuseWithoutSurfaceBounds`, `promouvoirDpe` dans advert_card.js via `data-score-details`, coupure `no_fusion_dpe_promote`). Gold top 1 +2/-0, platine +2/-0, silver +152/-0. Lot platine enrichi à 73 (localisations du 29/09 de l'utilisateur), gold 201 (réfs 181 et 102 rejetées).
- **Top 10 : fusion pour maison sans terrain annoncé ACTIVÉE (2026-09-30)** : seconde recherche de la fusion sans le plafond de parcelle (1,2-1,5 × habitable) posé par la stratégie (advert_card.js + `LocateReplay`, variante `no_house_fusion`). Gold top 10 +1/-0, silver +41/-0.
- **Top 10 de la fiche annonce : règles appartement ACTIVÉES + lot `appartement` + cache du banc (2026-09-30)** : pour CLASS_FLAT, `getFilters` pose `bati_inferred_flat` et `energie_dpe_date_surface`, retire `bati_piscine = 0` et l'emprise (miroir `LocateReplay`, variante `no_flat_rules`). Appartement top 1 79,3 → 93,0 %, silver +4,3 pts. Banc : 24 processus + tranches 2 × parallélisme (7 min sans cache), cache contrôlé `--cache` (clé = requête + config city, 5 % recalculés et comparés, 76 s en cache ; `NOCACHE=1`, `ADVERT_LOCATE_BENCH_CACHE_EPOCH`) ; vider `storage/app/advert-locate-bench-cache` après un import. Détail README s. 10.24.
- **Top 10 des terrains : ventes « parcelle + bande voisine », ACTIVÉ (2026-09-30)** : `NeighborLand` (core-city) forme paires et triplets de parcelles nues mitoyennes (`fr_parcel_adjoinings`) égales au terrain à 0,1 % près, crédit 95 % des points de surface (drapeau `parcel_surface_neighbor_adjoin`, variante `land_neighbor_adjoin`). Terrains nus (106) : top 1 50 → 51, top 10 77 → 91, 0 perte gold. Lot terrain : 19 terrains bâtis depuis la vente écartés (biais). Rejeté : « construction » en score (gain = biais). Corrigé : `BuildingFilter` en mode indicatif (`leftJoin() on null`). Détail README s. 10.23.
- **Banc du top 10 : lots de référence v2 (2026-09-30)** : silver 289 → 1 612 (empreinte DPE stricte, conso ET émissions), DVF 764 → 2 297 maisons (15 mois de sorties, terrain ±0,2 %, prix 90-100 %, faux estimés 3,4 % au lieu de ~7 %), gold audité par DVF + empreinte (203 retenues), deux nouveaux niveaux `terrain` (125 terrains à bâtir vendus) et `platine` (50, deux preuves concordantes) - constantes `Advert_locate_reference::TIER_TERRAIN` / `TIER_PLATINE`, `--refs=terrain|platine`. Sur ces lots, la production actuelle : top 1 72,2 %, top 10 88,1 % (4 237 annonces). Scripts et sauvegarde des anciens lots : `.insights/advert-locate-benchmark/lots-2026-09-30/`. Détail README s. 10.22.
- **(historique) Top 10 de la fiche annonce : terrain exact et date DPE sans étiquette, sous drapeau (2026-09-29)** : deux variantes de banc dans `AdvertFilterTuner` (`exact_land_bonus` → `parcel_surface_exact_bonus`, `dpe_date_letters` → `energie_dpe_date_letters`), moteur dans core-city. Cumulées : gold top 1 105 → 112 (+7/-0), top 10 136 → 140 (+4/-0) ; silver top 1 190 → 194 ; DVF top 1 509 → 545 (+44/-8), top 10 663 → 668. Simulées, à décider : fusion du top 10 avec une recherche sans borne de surface (gold 141, silver 260, DVF 678, 2e requête par fiche) et crédit voisines 0,8 + opportunité x0,5 (0 perte). Outils : `fast/compare.py TAG [BASE]`, `fast/fusion.py`, `diag-2026-09-29/` (`sim.py` = simulateur exact, `absents.php`). ⚠ Configuration de production strictement inchangée (`ref2` = `ex1` sur 1 258 annonces). Détail : `.insights/advert-locate-benchmark/README.md` s. 10.20.
- **Banc de mesure du top 10 - phase 3 : variantes de filtres (2026-09-25)** : 170 des 175 parcelles vraies absentes du top 50 sont exclues par le filtre DUR `parcel_surface_min/max` ; pour un appartement, `DpeStrategy` impose la surface de l'appartement comme borne de la parcelle de l'immeuble (88/90 exclus). `App\Services\AdvertLocate\AdvertFilterTuner` (variantes `flat_parcel_surface`, `wide_parcel_surface`, `soft_parcel_surface`, `parcel_multi`) applique apres `convertAdvertToFilters`, dans le banc (`--variant=`) et dans `AdvertController::card` via `ADVERT_LOCATE_TUNER` (**vide par defaut = aucun changement**). Mesure : `flat_parcel_surface` = silver top 1 38 → 57 %, top 10 39 → 62 %, appartements top 10 5 → 71/95, **0 perte sur le gold** ; retirer la borne de surface fait perdre 7 top 1 (rejete) ; ±12 % : gain faible, 2 pertes. Sans variante le banc redonne le run #1 a l'identique. Activation = decision utilisateur. Detail : `.insights/advert-locate-benchmark/README.md` section 9.
- **Banc silver 30 min → 8 min : filtre de commune (core-city) (2026-09-25)** : les appartements de grandes communes prenaient 70 a 206 s, dans le navigateur aussi ; correctif `GeographicFilter` (identifiants de communes resolus avant la requete, resultats identiques). Silver en config de production : 59,2 / 66,8 %. Preprod analysee (elle n'avait aucune statistique). Detail README s. 10.9.
- **Top 10 : poids DPE + date sur 3 dates ACTIVES (2026-09-25)** : `getFilters()` (`search.js`, contexte annonce) ajoute `energie_dpe_date_any=1` quand une date DPE est presente (miroir dans `LocateReplay::buildPayload`, cle exclue du comptage et de l'historique) ; nouveaux poids dans `DpeStrategy` (core-advert). Gold top 1 43,9 → 47,8 %. Parite navigateur verifiee. Detail README s. 10.8.
- **Top 10 - suite phase 3 et phase 4 (2026-09-25)** : `AdvertFilterTuner` gagne `parcel_combos` (REJETE : gold top 1 43,9 → 38,8 %), `dpe_date_any` (date DPE comparee aux 3 dates, core-city ; gold +1/0 ; ⚠ non cablee en production, le front ne transmet pas la cle) et `exact_points` (active `city.scoring_exact_points` le temps du rejeu ; neutre). Banc : `--weights='{json}'` (surcharge de `_scoring_weights`) et `--dump` (details de score des candidats). 🟥 `energieConso` du scoring = lettre DPE ; poids « lettres 0,5, date 10, piscine 1,4 » = gold top 1 43,9 → 47,8 % sans perte, a combiner avec `dpe_date_any` (silver seul 1/6). Rien active. ⚠ Lancer les rejeux un par un : 3 en parallele ont sature le disque de la preprod. Detail : README section 10.
- **Banc de mesure du top 10 - phase 2 : lot de reference gold / silver, audit par empreinte DPE, page interne (2026-09-25)** : document de reference complet `.insights/advert-locate-benchmark/README.md` (phases 1 et 2, mesures, pieges). Tables du **site** `advert_locate_references` (gold = parcelle liee par un agent, silver = parcelle designee par l'empreinte DPE ; statut `unreviewed|confirmed|suspect|rejected`, `audit_result`, `evidence`) et `advert_locate_runs` (migration `2026_09_25_000001`, structure seule). `App\Services\AdvertLocate\DpeFingerprint` : dans la commune du bien, DPE a meme date (l'une des 3) ET meme kWh/m2 ou kgCO2/m2 arrondis → ensemble de parcelles unique = silver (226 annonces en 1-2 s). `LocateReferences` : import gold depuis `advert_locations` (jamais ecrite), silver, audit (parcelle commune → confirmee, > 50 m → suspecte ; ⚠ deux parcelles qui se touchent ont une distance de 0 m : « meme parcelle » se teste par identifiant commun ; une decision humaine n'est jamais ecrasee). Commandes `advert:locate-references import|silver|audit|all|stats [--sample=N --insee=]` et `advert:locate-benchmark --refs=legacy|gold|silver|best --status= --save=`. Page interne `GET /outils/benchmark-localisation` (`AdvertLocateBenchmarkController`, vue autonome `advert_locate/index`, 404 hors `ADVERT_LOCATE_ADMIN_IDS`) : resultats, comparaison de deux versions (dont annonces **perdues** au top 1), lot filtrable, Confirmer / Rejeter / Suspecte / **Adopter la parcelle DPE** (gold rejetee + silver confirmee). **Mesures** : audit gold 37 confirmees / 8 voisines / 17 suspectes ; run #1 gold sans suspectes top 1 **44 %** (41 % avant), top 10 62 % ; run #2 silver (289 annonces, dont 224 hors lot gold) top 1 38 % mais top 50 39 %, maisons 55 %, appartements 5 % ; 🟥 158 des 175 parcelles silver absentes du top 50 sont DANS le cercle de l'annonce → ce sont les filtres qui les excluent (piste n°1 de la phase 3). Annonce 222592649 : AK0131 rejetee, ZL0016 (DPE) confirmee. ⚠ Echantillon silver restreint a `[2021-07-01, max(date_reception_dpe)]` (dates DPE d'annonce aberrantes jusqu'en 2028). ⚠ La page ne lance pas le benchmark (workers dans /administration, classes `App\` inconnues). Deploiement : `git pull` + `migrate` + `config:clear` + `route:clear` + `view:clear` (+ `ADVERT_LOCATE_ADMIN_IDS`).
- **Banc de mesure du top 10 de la fiche annonce (2026-09-25, phase 1 du plan « localisation d'annonce »)** : `App\Services\AdvertLocate\LocateReplay` rejoue côté serveur la recherche du bloc `quick-parcels-section` (moteur réel `HasSearch::loadParcels`, sans effet de bord : `getParams(..., false)`, pas de statistique d'activité, pas de cache de réponse) et `php artisan advert:locate-benchmark` (enregistrée dans `AppServiceProvider`) mesure le rang de la parcelle vraie sur `core_city_user.advert_locations` : top 1 / 3 / 10 / N, MRR, justesse du top 1 quand il dépasse 90 %, ventilation par stratégie, type de bien et mode de zone, liste des échecs, `--json` pour le détail. Options : `--source`, `--limit`, `--flx`, `--zone=auto|circle|none`, `--strategy`, `--top`, `--user`. **Parité prouvée à l'identique avec le navigateur** (parcelles, ordre ET scores) : 6/6 sans carte (onglet Claude in Chrome) et 6/6 carte prête (Chrome headless + Google Maps, `.insights/advert-locate-benchmark/parity_map.py`, zones d'intersection PostGIS comprises) - harness `parity_compare.php` + `parity_cases.php` / `parity_map_cases_2026-09-25.json`. **Pour y arriver, 4 comportements du navigateur ont dû être reproduits** : (1) `parcel_multi` n'est pas envoyé ; (2) la borne manquante d'une paire min/max part vide (`bati_emprise_sol_max=''`, convertie en null par les middlewares HTTP, d'où `ConvertEmptyStringsToNull` mimé) ; (3) un filtre « valeur + tolérance » est RECALCULÉ par le formulaire en `round(valeur ± valeur × % / 100)` (607-657) alors que la stratégie arrondit vers l'extérieur (606-658) ; (4) **🟥 l'ORDRE des filtres change le score** : `ScoringCalculator::calculateParamWeights` arrondit chaque critère à l'entier inférieur et donne TOUT le reste au dernier critère indicatif (mesuré : 47,7 contre 45,7 pour les mêmes filtres), d'où `orderLikeForm()` qui reproduit l'ordre d'affichage (catégories puis filtres, comme `AdvertController::card`). Défaut du moteur **laissé tel quel** (phase 3). **Mesure de référence (préprod, 226 annonces, zone auto)** : top 1 **41 %**, top 3 49 %, top 10 **62 %**, top 50 70 %, MRR 0,475 ; top 1 ≥ 90 % juste dans 19 cas sur 24 ; 5 biens absents de la préprod. Parcelles liées depuis le top 10 (`search_result`, 119) : rang rejoué = rang prod dans 62 %, ≤ 10 dans 87 % (écart = données préprod ≠ prod et filtres retouchés) ; **parcelles liées hors top 10 (`manual_parcel_tab`, 68) : 16 en top 1, 44 absentes des 50 premiers** - la cible d'amélioration. ⚠ Vérité terrain bruitée : l'annonce 222592649 (flx 2321506104) est liée à 49020000AK0131, **confirmé faux par l'utilisateur** (le DPE de l'annonce désigne 49020000ZL0016) ; 5 annonces ont plusieurs localisations différentes. **Télémétrie de `locateAdvert` réparée** : `buildBenchmarkData` (`lead-actions.js`) lit la stratégie dans `#advert_filterCategoriesWrapper[data-strategy]` (`window.currentStrategy` n'était jamais posé), exclut du rang les parcelles déjà liées placées en tête (elles décalaient `parcel_rank` depuis le 2026-09-21), enregistre les vrais scores et leur détail (nouveaux attributs `data-score` / `data-score-details` des cartes `filter_result_item(_thumb)`), les poids, les filtres réellement envoyés et les premiers filtres de l'annonce (`window._quickParcelsLastRequest` / `_quickParcelsOriginalFilters` posés par `launchQuickParcelsSearch`, d'où `has_filter_difference` enfin juste) et un `scoring_result` (score choisi, top 1, présence de la zone) ; `AcquisitionController::locateAdvert` ne fait plus `json_encode` avant le cast `array` du modèle (**les colonnes jsonb étaient stockées en chaîne JSON**). Vérifié par une vraie liaison dans le navigateur (rang 3 malgré la parcelle liée en tête, JSON natif, 7 critères détaillés), top 10 identique avant et après, 0 erreur console hors artefacts `map is not defined` de l'onglet en arrière-plan, PHPUnit 159 passés / 2 échecs préexistants. ⚠ **Environnement** : la séquence `core_city_user.advert_locations_id_seq` de la préprod vaut **2** alors que les lignes importées vont jusqu'à l'id 283 - toute nouvelle liaison prendra un id déjà existant (23505) tant qu'elle n'est pas recalée (`select setval('core_city_user.advert_locations_id_seq', (select max(id) from core_city_user.advert_locations))`). ⚠ `/filter/search/list` met sa réponse en cache 60 min : après une modification des vues de carte, le navigateur continue de servir l'ancien HTML. **Déploiement** : `git pull` + `npm run prod` + `view:clear` ; 0 migration, 0 route, 0 package.
- **Retrouver ses acheteurs / locataires (2026-09-23, Solutions A puis B)** : moteur `Rootsud\City\User\Services\LeadRequirementCriteria` (core-city-user, cf. son CLAUDE.md). **Cote site** : (1) `AcquisitionController::requirementFilterParams()` fusionne les parametres `req_price`, `req_price_tol` (0/5/10/15/20 %), `req_surface`, `req_rooms`, `req_financing[]`, `req_horizon` (3m/6m/12m) dans `index`, `kanban` et `kanbanMore` ; (2) modale `modal_acquisition_filters` : section `#acqFilterBuyerSection`, affichee seulement si le filtre type autorise acheteur ou locataire - sinon les `req_*` sont ignores (`buyerFiltersApplicable`), badge de filtres compte les criteres ; (3) pastilles `partials/_lead_requirement_chips.blade.php` sur `kanban_card` (4 au plus) et `lead_list_item`, les KPI prix/surface redondants sont masques ; (4) **vue « Carnet acquereurs »** : 3e bouton `data-view="table"` (localStorage `acquisition_view`), fragment `POST /acquisition/buyers` (`buyersTable`, 500 lignes au plus, `buyerTableData` : types demande ∩ filtre, tri `created_at|updated_at|budget_max|budget_min|date` NULLS LAST, derniere activite en UNE requete groupee - `Lead::latestActivity` n'existe pas), vue `buyers_table.blade.php` (selection multiple, copier les e-mails, export, bouton Matching si `lead_capability('matching')`, cartes sous 768 px avec cellules vides masquees), export `GET /acquisition/buyers/export` (`buyersExport` : CSV BOM `;` 18 colonnes, `useQuota('EXPORT_RESULT')` + limite `EXPORT_RESULT_LIMIT_CSV`, 432-435 geres cote JS par XHR blob) ; **Suite le meme jour (retour utilisateur)** : filtres favoris et bouton Export du carnet RETIRES (doublons du bouton Filtres et du menu `btn-export`, qui porte desormais l'option « Carnet acquereurs (CSV) » `.acqExportBuyers`, la selection cochee si la vue carnet en a une) ; colonnes fusionnees en 7 (Recherche = bien + zone, Echeance = date + financement, Suivi = statut + activite) et bascule en cartes pilotee par la largeur du PANNEAU (`@container bttable (max-width: 940px)`) : plus aucune barre horizontale en HALF, mesure de 390 a 1 129 px. ⚠ Le tri par budget n'est PAS possible en vue liste (`cursorPaginate`). ⚠ Non fait : relance groupee depuis le carnet (elle reste par bien), nombre de biens compatibles par ligne (seul un bouton vers l'onglet Matching). **Suite 2 (meme jour)** : filtre « Zone recherchee » retire (trop de zones), barre `.bt-toolbar` retiree (compteur deja dans `counter-value`, note de troncature sous le tableau) ; **synchro avec la legende carte** : 6 entrees `ModuleFilterSync` (`lead_req_price`, `lead_req_price_tol`, `lead_req_surface`, `lead_req_rooms`, `lead_req_financing`, `lead_req_horizon`), lues seulement si `acqBuyerSyncOn()` ; config et SQL dans core-city (cf. son CLAUDE.md). **Verifie** : harness `.insights/buyer-criteria-2026-09-23/harness.php` (legacy 2/2 defaut « Maison » reproduit, fixed 43/43, transaction annulee), E2E `e2e.sh` 16/16 (user 2) et 20/20 avec `EXPORT=1` (user 214295), navigateur bureau + iframe 390 px, 0 erreur console, PHPUnit 160 / 3 preexistants. **Deploiement** : tag core-city-user + `composer update` (site ET administration) + `npm run prod` + `view:clear` + `route:clear`.
- **Relancer les acquéreurs d'un bien et voir qu'il ne se passe rien (2026-09-23, Solution C)** : moteur dans core-city-user (`BuyerEngagement` = état des acquéreurs, `BuyerFollowup` = composition / envoi, cf. son CLAUDE.md). **Côté site** : (1) **section « Visites du bien »** (`_lead_seller_visits`, `data-section="visites_bien"`) ouvre sur le partial **`_lead_buyer_followup.blade.php`** : une ligne par acquéreur (nom, état coloré, pastille J+X, frise visite → CR → relance → réponse, note du visiteur), cases + « Relancer » ; **chaque visite est rangée DANS la carte de son acquéreur** (partial `_lead_seller_visit_item.blade.php`, `nested`, sans le nom répété ; les étapes « visite / CR » de la frise ne s'affichent que pour un acquéreur sans visite listée) ; seules les visites qu'aucun acquéreur du suivi ne porte (au-delà de la fenêtre de 120 j, visiteur non identifié) restent sous « Autres visites » (retour utilisateur du 2026-09-23 : un même contact apparaissait dans `bfu-block` ET dans la liste des visites). ⚠ Le balisage `.lbv-visit-item` / `.leadEventReport` est lu par `Acquisition.applyEventReportUI` : ne pas le renommer. **Refonte visuelle v2 du `bfu-block` (2026-09-23)** : en-tête « Acquéreurs N » + pastilles d'indicateurs (visites, à venir, offres, à relancer, dernier retour), carte par acquéreur avec avatar à initiales teinté par l'état (`data-tone`, barre latérale), frise horizontale Visite → Compte rendu → Relance → Retour (étape attendue en ambre `is-current`, 2 × 2 sous 576 px), avis de l'acquéreur en bulle, visites repliables (`.bfu-visits-toggle`, ouvertes d'office si une visite attend une action), cases maison sur input natif masqué. ⚠ La racine d'une carte reste `class="bfu-row"` / `"bfu-row is-silent"` EXACTEMENT (lue par l'E2E) ; « N j sans nouvelles » masqué pour une offre en cours ou un acquéreur plus intéressé. **Retours du 2026-09-23 (3e passe)** : ligne de titre « Acquéreurs N » retirée (doublon de la pastille de l'accordéon), la puce « Bien au point mort » passe dans les indicateurs ; l'étape « Retour » n'est cochée que pour une offre, un avis post-visite, ou un signal entrant POSTÉRIEUR à la dernière visite (et à la dernière relance) - une réaction portail antérieure à la visite ne compte plus ; puce du bon de visite `_lead_visit_documents` : « à signer · e-mail non envoyé » quand `metadata.delivery_failed_at` est posé (le statut reste 5, cf. core-city-user) et « envoi échoué » (ambre) pour un ancien bon en statut 4, avec explication au survol ; la relance cite l'adresse du bien visité (« Maison au 12 boulevard …, 66180 Villeneuve-de-la-Raho », seed #219/#220 à rejouer avec `FORCE=1`). **4e passe** : sur la fiche du BIEN, chaque bon de visite porte ses actions (télécharger, relancer la signature `.lvd-bon-relaunch` → `POST /acquisition/lead/proposal/{id}/relaunch`, signer manuellement `.lbv-mark-signed-btn` avec `data-lead-id` = lead du visiteur et `data-refresh-lead-id` = fiche affichée), mention « relancé le … » ; une visite passée sans bon affiche la puce « Bon de visite manquant » (comme sur la fiche du visiteur) ; bouton relancer aussi sur la carte du bon côté visiteur ; `markProposalSignedManual` et `relaunchProposalAction` scopés par l'accès au lead (plus par `user_id`), relance sans e-mail = 422 explicite. **Portail vendeur (même jour)** : un seul bloc « Offres reçues » (`_portal_offers_received`) = offres à décider (cartes avec Accepter / Contre-proposer / Refuser) puis historique des offres traitées (`.cp-offer-history`, 10 au plus, dernière contre-proposition rappelée) ; le récapitulatif `_key_data` n'est plus rendu pour les offres (paramètre `renderOffersSummary` jamais passé). Avant, deux blocs du même nom coexistaient, dont un vide « Aucune offre en attente de votre décision » dès que toutes les offres étaient traitées. Ordre des portails vendeur / bailleur : estimation et offres reçues AVANT le bloc documents / rendez-vous / échanges - ce bloc est extrait de `_narrative` en `partials/_bottom_row.blade.php` ; `seller` / `lessor` passent `deferBottom => true` et l'incluent après les offres ; acheteur / locataire : même principe (`deferBottom`), le questionnaire post-visite et « Mes offres » / « Mes dossiers déposés » passent avant le bloc documents / rendez-vous / échanges ; seuls les autres portails (garant...) gardent le rendu par `_narrative`. Le bloc « ⚠ Action requise · À signer » (et la modale `#cpSignModal`) est extrait de `_documents` en `partials/_pending_signatures.blade.php` : avec `deferBottom`, `_narrative` le rend juste après le parcours (avant biens proposés, estimation, offres) et `_bottom_row` reçoit `skipPending` pour ne pas le répéter ; sans `deferBottom`, `_documents` le rend comme avant. (2) **Modale `#modalBuyerFollowup`** (`_modal_buyer_followup.blade.php`, body-level dans `page_map_container`) + **`resources/js/buyer-followup.js`** (bundle `app.js`, `window.openBuyerFollowupModal(leadId, contactIds, channel)`, handler délégué `.buyer-followup-open`) : acquéreurs relançables (pré-cochés = « à relancer »), canal email / SMS (badge quota), picker `relance_post_visite`, aperçu éditable renvoyé seulement s'il est modifié ; après envoi `_refreshLeadPiecesSections` + fragments + timeline. (3) Trait **`App\Http\Traits\HandlesBuyerFollowup`** (sur `AcquisitionController`) : `GET /acquisition/lead/{id}/buyer-followup/preview`, `POST .../send` (`planaccess:ACCESS_CRM`, `Lead::sharedFor`, ne garde que les acquéreurs relançables de CE bien, 422 `no_buyer` / `no_recipient` / `sms_quota`, HTML assaini). (4) **Hero NBA** : bandeau « Bien au point mort » (primaire « Relancer N acquéreurs », secondaire « Faire le point avec le vendeur » `.ld-open-send-message`, « Voir les acquéreurs » ; jamais « Marquer fait » seul) ; **carte KPI « Acquéreurs »** (`--lkc-cols` recalculé) ; **pastille « ⏸ N j sans retour »** kanban + liste et point d'activité des cartes calculé sur les ACQUÉREURS pour un bien visité (`lead_activity_recency`, `lead_buyer_stalled_chip`, helpers `app/Helpers/buyer_followup_helpers.php`, 0 requête par carte grâce au `prime`) ; **filtre « Biens au point mort »** (chip `stalled` de `modal_acquisition_filters`) ; bloc dashboard `dashboard/_stalled_properties.blade.php` (rendu seulement s'il y en a). (5) **Portail** : `matchReaction` écrit enfin `reaction` / `reaction_at` sur l'épingle (lue par HotBuyerDetector) ; questionnaire post-visite `GET|POST /client/{token}/visit/{eventId}/feedback` (4 choix + raison + note ; GET sans écriture, POST = `client_feedback*`, activité sur les 2 leads, notification agent, tâche ciblée, « Je veux faire une offre » ouvre le dépôt d'offre), section `_visit_feedback_section` (acheteur / locataire). (6) `/parametres#preferences` : case **« Relance automatique par e-mail »** (`buyer_followup_auto_enabled`, **décochée par défaut**, dans `AgencyController::PREFERENCE_KEYS`). (7) `config/document_kinds.php` : `relance_post_visite` ; `routes/console.php` : `leads:buyer-followup-suggest` 8 h. **Templates** (données) : `.insights/seed-relance-post-visite-templates-2026-09-23.php` (**À REJOUER EN PROD** : #219 e-mail, #220 SMS, et bloc retours visiteurs / semaine sans activité du rapport hebdo #82). **Vérifié** : harness 50/50 (legacy 7/7), E2E HTTP `.insights/buyer-followup-2026-09-23/e2e.sh` **31/31** (compte test 214295, jeu créé puis supprimé), navigateur sur la fiche 2196 (lecture seule) bureau + iframe 390 px, 0 erreur console, PHPUnit 160 / 3 préexistants. ⚠ `app.js` / `app.css` sans hash en dev : hard-reload après build (styles absents constatés avant). ⚠ Un envoi de test part vers les workers : l'E2E attend leur passage avant de supprimer les leads (sinon `failed_jobs` ModelNotFound). **Déploiement** : tag core-city-user + `composer update` (site ET administration) + `migrate` depuis administration + `queue:restart`, puis `git pull` + `npm run prod` + `view:clear` + `route:clear` + le seed.
- **Bon de visite genere a la NAISSANCE de la visite (2026-09-23)** : signale « depuis le portail client, une demande de visite ne genere pas le bon de visite ». **Mesure** : 18 visites en base, **2** avec un bon lie ; le seul chemin qui liait un bon a un RDV etait la modale « Generer le bon de visite » + « Creer aussi un RDV ». **Livre (0 migration, 0 route, build JS, 1 script de modele a rejouer)** : (1) coeur de `generateBonVisite` extrait dans le trait **`App\Http\Traits\GeneratesVisitVoucher`** (`createVisitVoucherProposal` = template + PDF + proposition ; `deliverVisitVoucher` = envoi + activite), l'endpoint manuel devient une enveloppe (reponse JSON inchangee) ; (2) service **`App\Services\VisitVoucherService::ensureForEvent($event, $opts)`** : visite non annulee sur lead ACHETEUR/LOCATAIRE, proprietaire Hoguet + `ACCESS_CRM`, **idempotent** (un bon par RDV via `metadata.agenda_event_id`, verrou `pg_advisory_xact_lock` sur `pgsql_city_user`, livraison APRES commit), signature **electronique deposee dans l'espace client** (e-mail sinon SMS sinon portail seul), **repli papier + tache « Faire signer le bon de visite »** si le plan n'a pas `ACCESS_SIGNATURE` ou si les mentions Hoguet sont incompletes ; visiteur = contact du portail > participant RATTACHE au lead acheteur (jamais le contact du vendeur, prerempli dans « Modifier ») > pivot acquereur ; bien = `match_ref` P-/A- > `property_lead_id` > adresse du RDV. Il tourne via un `AcquisitionController` **initialise sur le proprietaire** (`initPage($owner)`) : fonctionne sans session agent. (3) Branche sur `confirmVisitTask`, `linkVisitTaskEvent`, `visitRequestAccept` (portail public) et `AgendaController::store` (champ `generate_voucher`, case « Generer le bon de visite » `#agendaVoucherOption` de la modale, cochee par defaut, visible seulement a la creation d'une Visite liee a un lead et hors flux « Modifier » d'une demande portail) ; `confirmVisitTask` pose enfin `property_lead_id`, et le RDV (confirmation et acceptation) porte l'**adresse du BIEN**, plus celle du lead acheteur. (4) `selfBooking` cree la tache `visit_request` (Confirmer / Modifier / Contre-proposer) qui lui manquait. (5) Fiche lead : bouton « Generer le bon » sur chaque RDV non lie (modale preremplie, section « Creer aussi un RDV » masquee, `agenda_event_id` transmis puis `link-event`), puce « Bon manquant » sur une visite passee sans bon. (6) Visite annulee ou supprimee : `AgendaEventObserver::updated/deleted` → `withdrawForEvent` passe un bon electronique encore a signer en `revoked` (10) + activite ; bon papier ou signe conserve. (7) 🟥 Le modele systeme **#66 imprimait `{{current_date_long}}` comme « Date de la visite »** (date de GENERATION) : script `.insights/bon-visite-template-visit-date-2026-09-23.php` (**A REJOUER EN PROD**, `$GLOBALS['APPLY']=1`, idempotent) → `{{#if visit_date}}...`, `visit_date` = « 1 octobre 2026 a 15h00 » (`VisitVoucherService::formatVisitDate`) injecte par le service et par la generation manuelle (`agenda_event_id` / `visit_at`). **Verifie** : harness `.insights/visit-voucher-2026-09-23.php` **42/42** (transaction annulee, Bus/Mail fake), course de 4 processus `.insights/visit-voucher-race-2026-09-23.php` = 1 bon, E2E HTTP `.insights/visit-voucher-e2e-2026-09-23.sh` **19/19** (fixtures `...-e2e-fixtures-2026-09-23.php` setup/cleanup, visiteur sans coordonnee = 0 envoi reel), texte du PDF relu (adresse du bien, date du RDV), navigateur (bouton RDV non lie → modale → bon lie au RDV ; case agenda visite/appel/sans lead), 0 erreur console, PHPUnit 160 / 3 preexistants, base rendue a l'identique. **Suite (decision utilisateur) : visite posee sur un lead VENDEUR / BAILLEUR** (modale agenda, case cochee) → `VisitVoucherService::moveSellerVisitToVisitorLead` : visiteur = participant Contact qui n'est PAS une partie du lead vendeur ; son lead acquereur / locataire existant est reutilise, sinon cree par `createLeadManual` (statut initial, taches, historique) avec `group_id` du lead vendeur ; le RDV passe sur ce lead (`saveQuietly`, `visit_scheduled` tire pour lui), le vendeur devient `property_lead_id` et est epingle (sans notification) ; sans participant visiteur → `no_visitor` + toast « ajoutez le visiteur en participant ». Harness porte a **54/54**, E2E a **23/23**. 🟥 **Corrige au passage (suite du meme jour)** : `rdvAction` (boutons « Confirmer » et « Annuler » d'un RDV au portail) lisait `$validated['preferred_date'|'preferred_time'|'note']` alors que `validate()` n'inclut pas les champs nullable absents - et le portail n'envoie JAMAIS `preferred_date` pour ces deux actions : l'activite levait « Undefined array key » (avalee, WARNING « RDV action activity failed »), donc **aucune ligne de timeline et aucune notification a l'agent**, alors que le client lisait « Votre conseiller est informe ». Defauts poses par `array_merge` apres validation ; A/B `.insights/rdv-action-missing-fields-2026-09-23.php` (regime web : legacy 0/3, fixed 3/3). Deploiement : `git pull` + `npm run prod` + `view:clear` + le script du modele #66.
- **Audit du cycle de visite : 15 défauts corrigés (2026-09-23, suite)** : relecture complète de la prise de RDV, du bon de visite et de la visite après la livraison du bon « à la naissance ». **Livré (0 migration, 0 route, 0 build JS)** : (1) **un seul e-mail « RDV créé » au client** - `AgendaEventObserver::created` supprimé (doublon du hook du modèle), le client passe par `Agenda_event::notifyClientRdvCreated()` (NotifyClientPortalEvent, ICS, préférences, suppression Mailgun) et n'est pas prévenu s'il est déjà invité en participant ; les créations qui rattachent des participants ensuite (`AgendaController::store`, `confirmVisitTask`, `visitRequestAccept`) posent `deferCreatedAnnouncements` puis appellent `announceCreated()` APRÈS (nom du visiteur enfin présent dans le V7 vendeur, pas de V7 en double) ; (2) repli par épingle : l'adresse est exigée même pour une seule épingle ; (3) `ensureForEvent` : contrôles Hoguet / plan AVANT tout déplacement de RDV ou création de lead visiteur ; (4) bon **nominatif** : signature et PDF du portail en 404 pour un autre contact, bons à signer filtrés par contact dans l'espace ; (5) `closePortalVisitRequest` retrouve une demande sans bien par `payload.task_id` ; (6) retrait : TOUS les bons à signer d'un RDV sont révoqués et les tâches ouvertes du bon supprimées, RDV qui cesse d'être une visite = bon retiré (`event_not_visit`) ; (7) **report** : bon non signé re-rendu avec la nouvelle date (`VisitVoucherService::refreshForEvent` + `GeneratesVisitVoucher::rerenderVisitVoucher`), bon déjà signé = activité d'alerte ; participants prévenus (`AgendaController::notifyVisitParticipants`, `AgendaEventClientMail` + ICS REQUEST), sauf RDV issu d'une demande portail (déjà notifié) ; (8) **suppression par l'agent d'une visite à venir** = annulation (`canceled_at`, participants prévenus avec ICS CANCEL, `visit_canceled` sur les DEUX leads, demande portail annulée) ; (9) occurrence détachée d'une série : garde `property_lead_id`, créée `withoutEvents` ; (10) bon papier signé → tâche « Faire signer le bon » cochée (`LeadProposalObserver`, nouveau) ; (11) libellé de statut des bons papier correct (`Lead_proposal::$statusLabels`) ; (12) case « Générer le bon de visite » masquée sans Hoguet / `ACCESS_CRM` ; (13) `generateBonVisite` : 409 `voucher_exists` si un bon non révoqué existe pour le RDV, `linkProposalToEvent` n'accepte qu'un bon de visite du lead ou du bien. Moteur dans core-city-user (cf. son CLAUDE.md). **Vérifié** : harness `.insights/visit-audit-fixes-2026-09-23.php` **27/27** (transaction annulée, Bus/Mail fake : 1 seul `rdv_created`, annonces différées, invité déjà prévenu, visite côté vendeur, V7 nommé, épingle unique hors adresse → null, visite annulée non « planifiée », CR d'appel ≠ CR de visite, 2 bons révoqués + demande annulée, demande retrouvée par tâche, signature d'un autre contact 404, compte Premium sans effet, report et annulation notifiés, ICS CANCELLED), harness `visit-voucher` **65/65**, E2E HTTP **23/23**, PHPUnit **160 / 3 préexistants**, 0 ligne de log. **Déploiement** : tag core-city-user + `composer update` (site ET administration) + `queue:restart`, puis `git pull` + `view:clear` + le script du modèle #66. ⚠ Limite : l'agent qui déplace / annule envoie un texte générique (`AgendaEventClientMail`), pas un modèle éditable.
- **Bon de visite cote vendeur + comptes rendus de visite sur template, CR vendeur envoye apres relecture (2026-09-22)** : demande « quand on est acheteur on peut generer un bon de visite, il faudrait aussi l'afficher cote lead vendeur ; le compte rendu de visite doit utiliser le template existant ; et pouvoir envoyer un compte rendu different au vendeur, aussi sur template ». **Mesure avant de coder** : le bon de visite vivait seulement sur le lead acheteur, `metadata.property_matched_lead_id` n'etait LU nulle part ; le « CR » reel etait une textarea libre (`PUT /agenda/events/{id}/report`, ni PDF ni `Lead_contact_visit`) ; `saveCrVisite` etait dormant (contact = premier pivot, `template_id` ignore, `report_notes` jamais ecrit, modale supprimee le 2026-05-27) ; `sendCrVisiteToVendor` ecrivait au contact du lead ACHETEUR avec le template DOCUMENT #74 comme e-mail ; « Organiser les visites » ne se cochait jamais cote vendeur. **Livre (Solution B : colonne `agenda_events.property_lead_id`, moteur dans core-city-user - cf. son CLAUDE.md)** : (1) **3 chemins de creation posent `property_lead_id`** : `AgendaController::store/update` (valide par `validateLeadId` = `sharedFor`, 422 sinon, null si egal a `lead_id`), modale bon de visite + formulaire inline `.lbv-plan-event-submit` (`client-portal-admin.js`, `data-property-lead-id` = `property_matched_lead_id` du bon), `ClientPortalPublicController::visitRequestAccept` (match `P-{id}`), et `linkProposalToEvent` recopie le lead du bon sur l'event ; `updateReport` tire `visit_completed` / `visit_report_filled` aussi sur le lead du bien. (2) **CR visiteur structure** : `.leadEventReport` porte `data-event-type-slug` + `data-lead-id` (`_lead_bon_visite`, `_lead_event_row`, `_lead_seller_visits`) ; sur une VISITE `acquisition.js` ouvre **`#modalCrVisite`** (`_modal_cr_visite.blade.php`, JS `resources/js/cr-visite.js`, `window.CrVisite`), la textarea legacy reste pour les autres types ; `GET /acquisition/lead/{id}/visit/event_{eventId}/cr` (`getCrVisite`) prefill ; `saveCrVisite` reecrit : lead visiteur = lead du RDV (le CR se saisit aussi depuis la fiche VENDEUR), 422 `not_a_visit`, contact = celui du bon lie sinon participant Contact sinon pivot acheteur / locataire (`resolveVisitVisitor`), `template_id` via `resolveDocumentTemplate` (#74, fork honore), **synthese ecrite dans `report_notes` + `report_at`** (« Interet : Tres interesse · Prix juge correct · Suite : Envisage de faire une offre » + points forts / reserves / note - c'est ce que lisent badges, timeline, hero et checkers), une seule proposal `cr_visite` par visite (mise a jour, pas de doublon), events tires sur le visiteur ET le bien ; `applyEventReportUI` extrait (mise a jour en place, pas de rechargement). (3) **CR vendeur** : `GET .../visit/{visitId}/cr-vendeur/preview` (`crVendeurPreview` : `has_report`, objet + corps e-mail resolus, retour vendeur par defaut, `vendor_proposal`) + `POST .../cr-vendeur` (`sendCrVendeur`, `hoguet` + `planaccess:ACCESS_CRM`, `channel=email|download`, `email_subject` / `email_html` assainis vers `custom_email_*`, proposal `cr_visite_vendeur` **sur le lead VENDEUR** contact vendeur, remplacee par visite, `User_document` CATEGORY_REPORT `portal_visible=true`, `SendModelByMail` en e-mail, `delivery_blocked` sans e-mail vendeur, activite sur les deux leads) ; `sendCrVisiteToVendor` et sa route **supprimes** ; modale **`#modalCrVisiteVendeur`** (`_modal_cr_visite_vendeur.blade.php`, `window.CrVisiteVendeur`) : canaux, picker kind `cr_visite_vendeur`, retour vendeur et notes editables, **apercu e-mail editable** (objet + corps `contenteditable`, recompose au changement de retour tant que l'agent ne l'a pas edite), bloc « un CR vendeur existe deja »). Bouton `.cr-vendeur-open` dans le partial partage **`_lead_visit_documents.blade.php`** (chips bon de visite / CR visiteur / CR vendeur) : **rendu seulement pour une visite PASSEE** et actif seulement apres le CR visiteur. (4) **Section « Visites du bien »** (`_lead_seller_visits.blade.php`, `data-section="visites_bien"`, accordeon Pieces des leads SELLER / LESSOR gate `$capHoguetDocs`) : `Agenda_event::forProperty($lead->id)->visitType()`, nom du visiteur (contact du bon > participant > **pivot de la visite > contact du lead visiteur** > `display_name` - ce dernier rend « Lead #N » sur un acheteur sans adresse, il ne lit que les relations deja chargees), lien vers le lead visiteur si `sharedFor`, badge, apercu du CR, documents, boutons. `PIECES_SECTION_PARTIALS` += `bon_visite`, `visites_bien` (fragment `pieces_html`). `config/document_kinds.php` : `cr_visite_vendeur` (Transactionnel, `hoguet.docs`, types 2 et 4). **Verifie** : harness **58/58**, E2E HTTP **70/70** (`.insights/visit-cr-vendeur-e2e-2026-09-22.sh`, session reelle : RDV avec `property_lead_id` → notification vendeur #84 ; 422 sur un lead d'autrui ; CR visiteur → PDF, `report_notes`, checkers vrais cote vendeur, GET depuis la fiche vendeur ; apercu resolu, visiteur « Prenom I. », POST download → proposal sur le vendeur, document portail, PDF relu par `pdftotext` sans nom complet ni `{{`, 0 job d'envoi ; fragments ; visite future sans bouton, passee avec ; 404 lead d'autrui et lead non concerne ; base rendue a l'identique - **snapshot / restauration des statuts et taches**, le pipeline reel reagissant aux events), navigateur en session reelle (fiche 525 : modale CR → POST reel → badge « CR saisi », chip, bouton actif sans rechargement ; modale vendeur → apercu, canal download → POST reel, telechargement, chip « CR vendeur genere », « Renvoyer au vendeur » ; fiche 575 : section « Visites du bien » avec la visite, « Modifier le CR » prerempli, apercu vendeur avec bloc « existe deja » ; iframe 390 px : les deux modales dans l'ecran, 0 debordement ; 0 erreur console), PHPUnit 160 / 3 preexistants, 0 ligne de log. ⚠ Le canal e-mail n'a pas ete exerce en reel (les contacts de test ont une vraie adresse) : chemin couvert par le harness. **Suite le 2026-09-23 (retour utilisateur)** : « sur mon lead 2198 j'ai fait une visite pour le lead 2196 epingle dans le matching, mais sur le 2196 je ne vois pas la visite ». La visite (event 239) avait ete creee par le bouton **« Visite » de la carte de matching** (`.match-visit-open` → `window.openMatchVisit` → modale agenda) : un **4e chemin de creation**, non cable la veille, donc sans `property_lead_id`. Livre : (a) `data-property-lead-id` sur les cartes de matching - `_match_portfolio_card` (lead acheteur : le bien = `$match->id`) et `_match_lead_card` (lead vendeur : **le RDV est desormais cree sur le lead ACHETEUR epingle `$match->id`, avec le bien = `$lead->id`**, conformement au modele visiteur / bien) ; `openMatchVisit` pose `window.pendingAgendaPropertyLeadId`, champ cache `#agendaEventPropertyLeadId` dans `agenda/_event_form.blade.php`, renseigne par le pending, par `events()` a l'edition (`extendedProps.property_lead_id`), remis a zero avec le lead, envoye dans `saveEvent` ; (b) **repli serveur** dans `AgendaController::store` : sans `property_lead_id`, une VISITE sur un lead acheteur / locataire est rattachee au bien EPINGLE dans son matching (`Agenda_event::resolvePropertyLeadFromPins`, core-city-user : une seule epingle vendeur / bailleur → elle, plusieurs → celle dont l'adresse est celle du RDV, ambigu → rien) - couvre aussi un ancien bundle en cache ; (c) rattrapage `.insights/backfill-agenda-property-lead-2026-09-23.php` (dry-run, `APPLY=1`, idempotent, **A REJOUER EN PROD** - dev : 10 visites examinees, 4 rattachees dont la 239, 6 sans bien deductible). Verifie : resolver (visite → 2196, RDV non visite → null, lead vendeur → null), E2E porte a **73/73** (cas 1b : visite sans lead du bien sur un acheteur a 3 epingles → resolue par l'adresse ; RDV de type signature → jamais rattache), navigateur : fiche 2196 « Visites du bien » = 1 visite (visiteur nomme, « CR saisi », bouton CR vendeur actif) et bouton « Visite » de la fiche 2198 → modale avec `property_lead_id = 2196`. ⚠ **Piege de mesure** : deux `select` dans un seul `psql -c` n'affichent que le DERNIER resultat - une epingle reelle 525 → 575 a ete prise pour absente puis supprimee par un nettoyage manuel, et recreee (id 142, `created_at` approximatif) ; le cas 1b insere en `on conflict do nothing` et ne retire que les ids posterieurs au snapshot. ⚠ **Deploiement** : tag core-city-user + `composer update` (site ET administration) + `php artisan migrate` depuis administration + `queue:restart`, puis `git pull` + **`npm run prod`** + `view:clear` + `route:clear` + `config:clear` + le seed en tinker (`--execute="\$GLOBALS['APPLY']=1; require '.insights/seed-cr-visite-vendeur-templates-2026-09-22.php';"`, dry-run sans `APPLY`) ; captimmo (1.0.25) exclu.
- **🟥 Fond de carte des widgets : CARTO filigrane ses tuiles, registre partage + repli automatique (2026-09-22)** : signale « les widgets utilisent un fond de carte qui demande une API key, avant on n'avait pas le message ». **Ce n'etait pas un depassement de volume** : depuis 2026-09 CARTO sert ses tuiles raster anonymes en **HTTP 200 avec « API KEY REQUIRED » dessine dans l'image** (mesure sur `a.basemaps.cartocdn.com/light_all/16/33055/24034@2x.png`, quel que soit le Referer), et sa cle gratuite (5 M tuiles/mois) est reservee a un usage **non commercial** : pas d'issue pour un SaaS. Les styles GL CARTO (`positron-gl-style`) restaient propres mais CARTO annonce l'extension au vectoriel. **Perimetre reel, mesure** : 5 engines de widget (`buildMapStyle` CARTO raster light_all / dark_all), 2 `result-map.js` (styles GL CARTO), 7 cartes de parcelle inline sur `tile.openstreetmap.org` (hors politique OSMF, sans dark), et **4 cartes de l'application** (`lead-actions.js`, `agenda.js` x2, `acquisition.js`) ; Leadget porte une copie divergente des widgets. **Livre (solution B du brainstorm, 0 migration, 0 route, 0 package)** : module unique **`resources/js/widget/src/map/basemap.js`** - registre par theme (rang 1 **OpenFreeMap** `styles/positron` / `styles/dark` : sans cle, sans limite, commercial autorise, CDN Cloudflare, aucun SLA ; rang 2 **VersaTiles** `gray` / `gray-dark` - noms CANONIQUES, `graybeard` / `eclipse` / `shadow` sont des 301 ; rang 3 **IGN PLAN IGN v2 raster**, Licence Ouverte, desature et assombri par `raster-saturation` / `raster-brightness-max` en dark), `createBasemap(maplibregl, mapOptions, theme, onStyleReady)` → `{ map, setTheme, destroy, current }`, `configureBasemaps(branding.basemap)` = interrupteur serveur sans rebuild (appele par les 5 engines ; le champ n'existe pas encore cote serveur). **Repli** : `error` MapLibre sans `sourceId` avant le premier `style.load` = style injoignable → rang suivant tout de suite ; avec `sourceId` = tuiles, bascule seulement si les 4 premieres echouent sans qu'aucune n'ait charge ; une bascule par rang, jamais de retour ; `AbortError` ignore ; `setTheme` conserve le rang. **`onStyleReady` est rejoue apres CHAQUE `style.load`** (initial, repli, theme) : c'est la que les engines re-posent leurs couches (`addParcelLayers`, contours de communes de la recherche - qui etaient perdus au changement de theme, defaut latent corrige). Attribution compacte posee par le module (ODbL), texte lu dans les sources du style (pas de `customAttribution`, sinon « © OSM » en double). **Cote application** : `resources/js/basemap.js` (importe le module des widgets, expose `window.Basemap`, ajoute au `mix.js` de `webpack.mix.js`) ; `acquisition.js` rejoue ses polygones de zones dans `drawRequirementPolygons()` via `onStyleReady`. 🟥 **`validateStyle: false` obligatoire** : les widgets chargent MapLibre **4.3.2** dont la validation rejette toute propriete plus recente que sa spec (mesure : les styles VersaTiles portent `sky.horizon-color` → « style injoignable » et saut direct au rang IGN) ; les styles viennent de fournisseurs de confiance, on ne valide pas. 🟥 **Piege de harness** : sous `--virtual-time-budget`, les tuiles VECTORIELLES sont demandees par le **Web Worker** de MapLibre, invisibles au temps virtuel de la page (et a `performance.getEntriesByType`) → capture vide et « 0 .pbf » alors que tout fonctionne ; un `setInterval` aggrave (chaque timer fait avancer le temps virtuel). Le harness `.insights/basemap-fallback-2026-09-22/run.py` pilote Chrome headless **en CDP, en temps reel**, et prouve le rendu par le nombre de couleurs de la capture. **Verifie** : harness **4/4** (normal z5 et z17,5 → `ofm-*` avec 1 500 a 3 100 couleurs ; OpenFreeMap bloque par DNS → `vt-*` ; les deux bloques → `ign-*` ; 0 requete `cartocdn` / `tile.openstreetmap`), navigateur en session reelle : page `/w/{slug}` (theme auto → `styles/dark`, canvas + attribution dans le shadow DOM), fiche lead 1475 (`#leadReqMap` → `styles/positron`), `Agenda.renderShowMap` et `initLeadAddrFormMap` (canvas + attribution), **0 requete CARTO / OSM, 0 erreur console** ; bundles widgets Infoparcelle ET Leadget reconstruits (0 residu). ⚠ **Onglet Chrome en arriere-plan** : MapLibre demande le style, le TileJSON et le sprite mais ne rend rien - verifier les requetes, pas le rendu. **Deploiement** : `git pull` (les **6 bundles widgets sont commites**, cache client 1 h) + `npm run prod` + `view:clear` ; Leadget : `git pull` (bundles commites). **Non traite, decision a part** : les fonds OSM de `core-city/maplibre.js` (`showMapl`) et `pricemap-static.js`, et `cartocdn.com` encore dans l'allow-list de `PdfTileProxy` / `TileCacheController`.
- **🟥 Recherche depuis une annonce sur mobile : top 10 « vide » et liste vide sous un compteur non nul (2026-09-22)** : signale « sur l'application mobile, dans une annonce, un count me renvoie 50 parcelles et la recherche est vide ; le top 10 est vide alors que sur PC il est rempli ; jamais en desktop ». **Mesure avant de coder, dans un onglet ou Google Maps ne s'initialise pas (= telephone lent, ou lien ouvert depuis une notification)** : la requete du top 10 (`POST /filter/search/list`, `limit:10`) partait **sans zone** (`#map-search-input` sans `data-code`) → **400 « Vous devez d'abord selectionner un secteur... »** → le `.fail` d'`advert_card.js` rendait ce 400 comme l'etat vide « Aucune parcelle correspondante » : un faux resultat vide, indiscernable d'un vrai. Cause : la zone de l'annonce est posee par le callback du menu Annonces (`menu-map.js`, `localiseOmnibarFromAdvertCard(map)`) APRES `initQuickParcelsSearch()`, et ce callback depend de la globale implicite `map` (`cleanlayerResult`, `cleanlayerAdvert`...) : sans carte prete il s'interrompt AVANT de poser la zone. ⚠ Le brainstorm supposait « XHR sans `.fail` qui bloque le bloc » : FAUX, le `.fail` existait (l.1626), c'est son RENDU qui masquait l'erreur. **Livre, 0 migration, 0 package** : (1) `window.applyAdvertCardZoneToOmnibar()` (advert_card.js) pose la zone depuis `#advertCard` (`data-layer/code/extra/title`) **sans carte ni requete** - appelee EN TETE du callback du menu Annonces et en repli dans `launchQuickParcelsSearch` quand `searchZoneIsSet(city, draw)` est faux ; `localiseOmnibarFromAdvertCard(null)` differe cadrage et selection a `getMap` ; (2) tout echec du top 10 = encart nomme + « Reessayer » (`window.searchFailureBox` / `searchFailureMessage` dans filter.js, meme composant que l'echec des menus), plus jamais l'etat vide ; un XHR termine (`readyState 4`) ne bloque plus `initQuickParcelsSearch` ; (3) `getResultList` : `status 0` (coupure, app mise en arriere-plan) = encart + **rejeu automatique** sur `visibilitychange`/`online`, uniquement pour une requete restee SANS reponse (0 doublon `stats_parcel_search`) ; (4) 🟥 `postResult` : un echec de la couche CARTE ou du COMPTEUR ne masque plus la liste et n'abandonne plus sa requete (`hideResultList` + `abort` + bascule d'onglet retires - c'etait le chemin « compteur affiche, liste vide ») ; rendu de la couche carte differe a `map.loaded` si la carte n'est pas prete ; (5) mobile avec carte masquee : la couche carte (geojson, jusqu'a 20 000 objets) est differee a `showMap` (`flushDeferredMapSearch`, responsive.js) ; (6) une recherche demandee pendant `_advertInitializing` est mise en attente et rejouee (`flushPendingAdvertSearch`), filet de 2,5 s qui relache le drapeau (`_advertInitTimer`). ⚠ **Une trace client (`search-trace.js`, item « Diagnostic recherche » du panneau Aide, route `POST /log/client`, `ClientDiagnosticController`) a ete livree puis RETIREE le meme jour sur demande utilisateur** : il n'en reste rien dans le code. ⚠ **H3 (payloads compteur/liste differents sur mobile) reste NON mesuree sur un vrai telephone** : sans la trace, la mesure se fera par les outils de developpement distants (chrome://inspect sur la WebView) en comparant les payloads de `POST /filter/search/count` et `POST /filter/search/list`. **Verifie** : A/B sur l'ancien bundle (400 + etat vide reproduits dans l'onglet sans carte), puis sur le nouveau : top 10 rendu (10 cartes, zone derivee de la fiche), 404 force → encart → Reessayer → 10 cartes, liste `status 0` (URL detournee vers un port ferme) → encart + compteur conserve → rejeu `online` → 42 cartes, couche carte 404 → liste intacte (42, barre visible), recherche mise en attente puis rejouee, iframe 390 px : top 10 10 cartes, liste 42, carte differee puis envoyee a `showMap(0)`, recherche classique non regressee (compteur 8 129, 42 cartes, carte 200), PHPUnit **160 / 3 echecs preexistants**, 0 ligne de log imputable (3 ERROR « Class Controller not found » = recette AVANT la correction de la classe de base : les controleurs du site etendent `Rootsud\Core\Http\Controllers\Controller`, `App\Http\Controllers\Controller` n'existe pas). ⚠ Les `ReferenceError: map is not defined` de la recette (`drawAdvertInfoCombined`, `layerResult` du top 10) sont l'artefact connu de l'onglet cache. Deploiement : `git pull` + **`npm run prod`** + `view:clear` + `route:clear`.
- **Menu Leads « page blanche » sur un autre poste : chargement de fragment durci, 3 defauts latents corriges (2026-09-22)** : signale « sur le PC d'un collegue, `menuOnMapContainer-btn-left-menu-acquisition` change l'URL mais la page reste blanche, il faut rafraichir ; jamais chez moi ». **Non reproduit** en dev sur 8 etats par poste (carte non initialisee, vue liste memorisee, mode MAP, mode FULL, largeur 1096 px, double clic, CSRF perime, deep-link lead) : le fragment se charge a chaque fois. **Mais 3 defauts reels sur ce chemin, tous prouves** : (1) 🟥 `map` est une globale IMPLICITE (jamais declaree, `map = m` dans le callback Google Maps) — avant l'init de la carte (PC lent, onglet en arriere-plan, clic rapide), `activateLeadsOverlayer` / `activateOpportunityOverlayer` levaient `ReferenceError: map is not defined` dans le callback du menu, ce qui **interrompait la suite du callback** (celui passe par l'appelant : `_showStatsView`, ouverture d'un lead…) → helper `_withLeadsMapReady` / `_withOpportunityMapReady` (execution immediate si `map` existe, sinon differee par `getMap()` et seulement si le menu est encore ouvert) + `deactivate*` retournent avant tout appel carte quand `map` n'existe pas ; (2) 🟥 `loadPostContentMenu` emettait son `$.post` dans un **`.ready()` de jQuery 3, qui est une promesse** : `getLeftMenuXhr` n'etait assigne qu'au tick suivant, donc deux appels rapproches (dashboard puis Leads dans le meme tick, mesure) ne s'annulaient pas et **la derniere reponse arrivee occupait le panneau avec l'URL de l'autre menu** → requete emise synchronement, l'abort fonctionne (mesure : dashboard → 0, acquisition → 200) ; (3) `$.post` **sans gestionnaire d'echec** : un fragment en 404/419/500 laissait le panneau sur son loader (ou vide sous 1200 px si `refreshScrollSize` levait avant le `fadeIn`), sans un mot et sans autre issue que F5 → `.fail()` (abort ignore, 401/503 laisses aux handlers globaux) rend un encart « Ce contenu n'a pas pu etre charge (erreur N) » + **Reessayer** (relance la meme requete) + « Recharger la page » ; `fadeIn` garanti par try/catch autour de `refreshScrollSize`, callback du menu isole par try/catch + `console.error('[menu] callback', e)`. ⚠ Le CsrfManager du core (`csrf-manager.js`) **retente deja un 419** apres `GET /_core/csrf-token` (meta corrompue → 419 → nouveau token → 200, verifie) : une session expiree n'explique pas le symptome. **Verifie** sur le bundle recompile : clic Leads carte absente **0 erreur** (avant : ReferenceError), course dashboard→Leads **panneau = acquisition**, POST en 404 → encart + Reessayer → kanban **7 colonnes**, non-regression des **9 autres menus** (0 erreur, contenu present), 0 ligne de log. ⚠ `npm run prod` + `view:clear` ; aucun package touche. ⚠ Si le collegue reproduit encore : lui demander d'ouvrir la console (F12) — l'encart d'erreur et `[menu] …` nomment desormais la cause.
- **🟥 Un lead cree en un clic naissait VIDE, et « Modifier le projet » ne demandait jamais la parcelle (2026-09-22)** : signale « depuis le menu Annonces, `parcel-locate-action` puis « Oui, ajouter aux leads » cree bien le lead, mais `modalAddLead` s'ouvre sans type de bien, sans surface, sans pieces, alors qu'on sait tout de la parcelle et de l'annonce ». **Mesure avant de coder** : le mecanisme de prefill existait (`GET /acquisition/lead/prefill`, cascade `parcel_user_details` > annonce localisee vivante > BDNB, ~40 champs) mais **un seul chemin sur huit** s'en servait - la modale, et seulement apres le choix Vendeur/Bailleur. Les creations en UN CLIC (`POST /acquisition/lead/create` depuis `createLeadFromLocatedParcel`, `.btn-add-lead` des cartes, `.opp-add-lead-btn` des Opportunites, et `locateAdvert` avec `create_lead`) n'appelaient jamais la cascade : sur 492 leads non types avec parcelle, **460 sans `type_bien`, 457 sans `surface`** ; source `advert` : **24 sur 24** vides. Et le symptome exact : `openEditLeadModal` ne posait **jamais** de `prefill-promise`, donc `_applyParcelPrefillIfReady` (declenchee au choix du type) sortait sur `!promise` - la modale d'edition ne demandait rien a la parcelle. `.btn-add-lead` envoyait en plus un `advert_flx_id` que `createLead` **ignorait**. **Livre (site seul, 0 migration, 0 route, 0 package)** : (1) **`App\Services\LeadPrefillResolver`**, resolution UNIQUE - corps de `leadPrefill` deplace tel quel (`forParcel`, reponse JSON identique au md5 sur 3 parcelles), `toLeadColumns` (`type_bien` / `surface`), `toRequirementData($prefill, $prefix)` = inverse exact de la collecte du submit (memes cles `{prefix}BatiType|SurfHab|SurfLand|Rooms|Bedrooms|Dpe|Ges|ConstructYear|Parking|Balcon|SurfSejour|Standing|Emplacement|View|Exposition|Features|...`, specifiques en booleens, jamais de valeur vide ni de 0, `Price` / `Floor` **uniquement** depuis une annonce au type d'offre coherent seller<->sale, lessor<->rent, tableau vide pour un prefixe de demande), `suggestRequirementType` (annonce de vente -> Vendeur, de location -> Bailleur), `userScopeIds` (acteur + proprietaire du lead + `getTeamUserIds`, donc **l'annonce localisee par un equipier est vue**) ; (2) **`buildOneClickLeadData`** dans `createLead` et `locateAdvert` : `type_bien` / `surface` toujours poses, et **typage automatique + `requirement_data` complete des qu'une annonce est localisee sur la parcelle** (decision utilisateur du 2026-09-22, qui inverse la regle « un lead issu d'une annonce reste non type » du 2026-09-17 : le lead entre dans le matching et le pipeline type des sa creation - `afterLeadCreated` emet `lead_qualified`) ; `advert_flx_id` localise l'annonce d'abord (`firstOrNew`, une seule ligne pour deux creations) ; dans `locateAdvert` le lead est cree **apres** la localisation (sinon le prefill ne la voit pas) et la reponse porte `offer_type` + `suggested_requirement_label` ; `createLeadModal` complete les colonnes quand la modale est soumise sans type ; (3) **JS** : `openEditLeadModal` charge la promesse (`lead_id` transmis au GET), `_applyParcelPrefillIfReady` ne remplit que les **champs vides** (input non vide, groupe de chips avec une `.active`, case cochee, ensembles Features / Exposition / specifiques laisses tels quels des qu'un element est actif) - regle unique en creation comme en edition ; swal « Creer un lead vendeur ? / Oui, creer le lead vendeur » ; **le titre de section (« Annonces immobilieres », « Acquisition ») n'est plus envoye comme adresse**, le serveur resout l'adresse BAN. ⚠ **Limite constatee, non traitee** : `#leadFeaturesGrid` n'a pas de chip `hasGarden` / `hasTerrace` / `hasJacuzzi` alors que le prefill (et donc `requirement_data`) les porte depuis l'annonce - la fiche les affiche, la modale non, et un re-enregistrement depuis la modale les perd (preexistant : l'ancien prefill posait deja `addClass('active')` sur des chips absentes). ⚠ Un lead CREE non type (parcelle sans annonce) le reste : le choix du type appartient a l'agent, la modale comble alors les lacunes au choix du type. **Verifie** : harness `.insights/lead-prefill-oneclick-2026-09-22.php` **48/48** en transaction annulee (perimetre, sans annonce, vente, location fabriquee - loyer jamais pris pour prix de vente -, deux annonces = la plus recemment localisee, annonce d'un equipier, forme de `requirement_data`, `buildOneClickLeadData` + `createLeadManual` reels, relecture par `FeeController::buildLeadEstimationPrefill`) ; E2E HTTP `.insights/lead-prefill-oneclick-e2e-2026-09-22.sh` **21/21** sur les vraies routes en session reelle (advert -> Vendeur + `sellerPrice` 310 000 + adresse BAN, opportunite -> non type + `type_bien`, `locateAdvert create_lead` -> lead apres localisation, `advert_flx_id` x2 -> 1 localisation, prefill avec `lead_id` inaccessible ignore, modale sans type) ; **navigateur** sur le bundle recompile : `locateAdvertThenAskLead` reel -> swal « Creer un lead vendeur ? » -> lead 2193 type Vendeur avec 11 cles de criteres -> « Modifier le projet » **ouvre directement l'etape 2 remplie** (maison, 148 m², 5 pieces, 470 000 EUR, DPE C, 2008+) ; lead non type -> choix Vendeur -> terrain 238 m², 5 pieces, 1975-1984, equipements remplis ; lead 1560 deja renseigne -> surface 100 et 3 pieces **conserves** alors que la parcelle propose 120 et 4, seules les lacunes comblees ; 0 erreur console ; PHPUnit **160 passes / 3 echecs preexistants** (ExampleTest + les 2 Pipeline*RequirementType*) ; 0 ligne de log ; leads et localisation de test supprimes, base rendue a l'identique. **Rattrapage a rejouer en prod** : `.insights/backfill-lead-physical-columns-2026-09-22.php` (dry-run par defaut, `--apply` ; en dev **444 `type_bien` + 33 `surface`** sur 461 leads examines ; option opt-in `--type-from-adverts` pour typer en plus les leads `advert` existants dont la parcelle porte une annonce, **sans** evenement de pipeline). ⚠ **Deploiement** : `git pull` + **`npm run prod`** + `view:clear` + le script de rattrapage ; aucun tag, aucun `composer update`.
- **Consentement RGPD : timeline, date visible, peremption et avertissement a l'envoi (2026-08-26)** — signale : « quand on modifie un contact et qu'on ajoute le consentement, on ne le voit pas dans la timeline du contact, meme chose depuis les widgets ; il faudrait la date d'acceptation et passer le consentement en rouge une fois perime ». La brique de fond (journal append-only `contact_consents`, accesseurs de fraicheur, 3 points de journalisation) vit dans les packages — cf. `core-city-user/CLAUDE.md` et `core-city/CLAUDE.md`. **Cote site** :
  - **Badge unique** `contact/partials/_consent_badge.blade.php` (parametres `$contact`, `$compact`) : **5 etats** derives de `Contact::consent_status` — vert (valide), ambre (a rafraichir), rouge (perime), rouge (refuse), **gris** (non renseigne). Consomme par la fiche contact (`contact/show.blade.php`) ET la fiche lead (`acquisition/partials/_lead_contacts.blade.php`, variante compacte) : les deux vues ne peuvent plus diverger. **La date est rendue en clair dans le badge**, plus seulement dans le `title=` — au doigt, sur mobile, un `title` n'existe pas.
  - ⚠ **L'etat « inconnu » passe du JAUNE au GRIS** (`.lc-badge-consent.is-unknown`, `acquisition.scss`) : il concerne **213 contacts sur 233 (91,4 %)** du parc (mesure du 2026-08-26). Le peindre en ambre noierait `stale` et `expired`, les deux etats qui appellent une action reelle. L'ambre est desormais reserve a « a rafraichir bientot ».
  - **Timeline** : `contact.js::getTimelineDotConfig` traite le type `consent` **hors de la table indexee sur le seul type** — la couleur porte le sens (vert `#3ac47d` accorde / rouge `#d92550` retire), elle ne peut donc pas venir d'une table `type -> couleur`.
  - **Avertissement NON BLOQUANT a l'envoi**, en **2 points seulement** : (1) `contactOptions` expose `consent_status`/`consent_warning` par contact, et `leadContactPicker` (`phase2-actions.js`, helper `_consentNotice`) l'affiche sous le nom dans le picker — l'agent voit l'etat du destinataire **avant** de composer ; `granted` ne rend rien (signaler ce qui est normal ferait du bruit) ; (2) au retour, un intercepteur **global** `ajaxSuccess.consentWarning` relaie le drapeau, sur le modele de `delivery_blocked` (lot signature du 2026-08-05).
  - 🟥 **Pourquoi ni dans le job, ni dans les controleurs** : `SendModelByMail`/`SendModelBySms` sont **asynchrones** (un avertissement leve dans le worker n'atteindrait jamais l'agent) et comptent une vingtaine de call-sites. Le drapeau est donc pose par une **ecoute de `JobQueued`** (`AppServiceProvider::boot`) qui resout le destinataire en base depuis `getRecipientEmail()`/`getRecipientPhone()` (getters ajoutes aux 2 jobs) et alimente le singleton de requete **`App\Services\ConsentWarningCollector`** ; le middleware **`App\Http\Middleware\AppendConsentWarning`** (pousse dans le groupe `web` par `pushMiddlewareToGroup` — le Kernel local est ignore, cf. la regle des alias) l'injecte dans la reponse JSON. **Un seul point d'injection couvre tous les call-sites, y compris ceux ajoutes plus tard.** Le middleware n'ecrit que sur un JSON objet dont la cle est absente : aucun contrat de reponse existant n'est altere.
  - ⚠ **L'avertissement ne bloquera pas** : 91 % du parc est a l'etat « consentement inconnu », un blocage strict paralyserait l'usage des le deploiement. L'envoi part, l'agent est informe.
  - **🟥 Defaut preexistant corrige au passage** : `ContactController::update` (et `store`) faisait `$contact->avatar->item->getUrl()` en chaine nue — or **21,3 % des contacts n'ont pas d'avatar** (49 sur 230, mesure) → `Attempt to read property "item" on null` → **500 sur l'enregistrement de tout contact sans photo**, alors que la modification etait **deja ecrite en base**. Sans ce correctif, l'enregistrement du consentement depuis la fiche contact restait invisible cote client. `optional(optional(...))` sur les 2 occurrences + garde 404 si `editContact` rend `false`.
  - **Verifie** : harness **21/21** et E2E **21/21** (3 origines reelles), avertissement **15/15** + **8/8** (l'ecoute `JobQueued` voit reellement passer un envoi : perime → avertissement, frais → aucun, SMS inconnu → avertissement, destinataire hors base → aucun, autre job ignore) ; navigateur : **5 etats** rendus avec leurs couleurs distinctes, timeline avec ses pastilles, badge de la fiche lead, picker d'envoi ; PHPUnit **156 tests / 6399 assertions**, 3 echecs **preexistants prouves par A/B** ; 0 erreur console, 0 erreur de log, base rendue a l'identique (233 contacts, 0 residu). Harness : `.insights/consent-rgpd-2026-08-26.php`, `consent-rgpd-origins-2026-08-26.php`, `consent-warning-e2e-2026-08-26.php`, `consent-warning-jobqueued-2026-08-26.php`.
  - ⚠ **Aucun contact reel n'est en rouge au deploiement** (consentement le plus ancien : 2026-06-10, soit 2,5 mois) — la recette des paliers exige un contact fabrique a 40 mois. Le vrai volume, ce sont les 213 contacts « non renseigne » qui passent en gris.
  - ⚠ **Deploiement** : tag **core-city-user** + tag **core-city** + `composer update` sur **rs_infoparcelle_application ET administration** + **`php artisan migrate`** + `queue:restart` + **`npm run prod`** (et non `dev` : `public/` est gitignore, le bundle doit etre recompile sur l'environnement cible) + `view:clear`. **captimmo et rootsud exclus.** ⚠ **Rien a builder pour les widgets** : leurs 5 bundles Rollup (`resources/js/widget/*-widget.js`) sont **suivis par git** et servis depuis `resources/` (`WidgetScriptController:77`), donc deployes par le `git pull` - le `npm run build` de `resources/js/widget/` se fait **en dev, une fois**, et son resultat se commite. Puis rejouer `.insights/backfill-contact-consents-2026-08-26.php --apply` (20 lignes attendues, idempotent).
- **🟥 Avis de valeur genere depuis la FICHE LEAD : photo de couverture et annexes perdues (2026-08-26)** : signale - « depuis la fiche parcelle la photo de couverture et les annexes cochees sortent bien dans le PDF ; depuis la fiche lead (bouton `ld-estimation-pdf`), j'ai la 3D a la place de la photo et aucune annexe a la fin ». **Deux causes independantes, toutes deux dans `ActionController`.** **(1) La couverture n'est pas stockee au meme endroit selon le contexte** : `ParcelUserController::saveSelectedCover` ecrit dans `parcel_user_details.selected_cover` **sauf** quand un `lead_id` est transmis - la fiche parcelle ouverte via « Modifier » depuis un lead ecrit alors dans **`lead_photos.is_cover`**. Or `saveEstimationData` ne lisait QUE `selected_cover` pour poser `Estimation_media.cover` → **aucun media ne portait `cover=true`**, et `PdfParcelsAvis::resolveCoverAndMap` retombait sur le `coverType` du theme (3D/2D/satellite). Mesure sur le cas signale : `lead_photos.is_cover=true` (media 1647) mais `estimation_medias.cover=false`, et **aucune ligne `selected_cover`** pour cette parcelle. ⚠ Le filet qui existe dans `PdfParcelsAvis` (l.372-386) est **inoperant** : il construit un pseudo-objet dont `value` est un `parcel_user_media_id`, compare ensuite a `Estimation_media.id` - deux espaces d'identifiants differents. Corrige cote site (0 fichier de package) : en contexte lead, `$selectedCoverValue` est resolu depuis `Lead_photo::is_cover`. **(2) Les annexes n'etaient persistees qu'a la generation** : le bouton PDF de la fiche lead poste `p` + `link_type` + `link_id`, **sans `document_ids`** (il n'a pas l'onglet Annexes sous les yeux), et l'enregistrement de l'estimation (`postSaveEstimation`, mode « Enregistrer » quand un lead est lie) ne transmettait rien non plus → `estimation_document_annexes` restait **vide** et le job recevait `[]`. Livre : `syncEstimationAnnexes()` appelee depuis `saveEstimationData` (donc sur les DEUX routes) - la requete porte une selection → elle fait autorite et remplace la table ; elle n'en porte pas → on **reprend la selection deja enregistree**. Le controle d'appartenance des documents (perso + annexes d'agence) est repris tel quel. ⚠ **`annex_selection_provided` est indispensable** : jQuery n'emet **aucun** parametre pour un tableau vide, donc « l'agent a tout decoche » et « l'ecran n'a rien transmis » arrivent tous deux sans `document_ids` - sans ce drapeau, decocher toutes les annexes serait impossible. Cote client, `window.applyAnnexSelectionToPayload()` ne le pose **que si `.annexes-modern-wrapper[data-loaded="true"]`** : un enregistrement declenche avant la fin du pre-chargement (2 GET) n'efface donc jamais la selection persistee. ⚠ **L'onglet Annexes precoche desormais la selection enregistree** (`data-annex-selected`, expose par `TabEstimController` depuis `Estimation_document_annex`, ordre `sort_order` respecte) : sans cela, rouvrir la fiche parcelle depuis le lead recochait les defauts `auto_attach` et un simple « Enregistrer » ecrasait le choix reel. ⚠ **Limite connue, assumee** : tout decocher puis enregistrer ecrit bien « aucune annexe » (le PDF n'en porte aucune), mais a la reouverture les defauts `auto_attach` sont recoches - la table vide ne distingue pas « aucune annexe voulue » de « jamais configure ». **0 migration, 0 route, 0 fichier de package** → aucun tag, aucun `composer update` ; `npm run dev` + `view:clear`. **Verifie** : harness E2E `.insights/avis-lead-cover-annexes-2026-08-26.sh` qui **reproduit d'abord les deux defauts** avec le code d'origine (**0/4**, `git show HEAD:` restaure pour l'A/B) puis **4/4** apres correctif, en session reelle sur les vraies routes (`/action/parcel/estimation/save` puis `/action/parcel/avis` **sans aucune selection transmise**, comme le bouton de la fiche lead) ; PDF reellement produit par le worker (11 pages, 1,04 Mo) ; `estimation_medias.cover` designe bien le media choisi sur le lead. Confirme par l'utilisateur en conditions reelles.
- **Dashboard, bloc « Annonces » : les favoris etaient invisibles sans annonce localisee (2026-08-24)** : signale sur `/dashboard` - « si on a des annonces favorites et aucune localisee, on ne les voit pas ». Le bloc s'affichait bien (le gate d'`index.blade.php` couvre le OU), mais `_my_adverts.blade.php` codait **en dur** l'onglet actif sur « Localisees » (`nav-link active` + `tab-pane fade show active`) : un compte sans localisee ouvrait donc sur l'etat vide « Aucune annonce localisee », les favoris restant derriere un onglet qu'il fallait deviner. L'onglet ouvert est desormais **calcule** (`$defaultTab` : « Localisees » sauf si aucune localisee et au moins un favori). Les deux onglets restent affiches (leur etat vide porte un CTA utile). **Second defaut, independant, trouve en mesurant** : `HasDashboardView` prenait la **fenetre des 10** plus recents AVANT le filtre `is_online`/`archived`/masquees - les annonces hors ligne consommaient les places et la liste sortait amputee, alors que le compteur, lui, annonçait le total filtre. Mesure sur un compte reel : **39 localisees, 22 visibles, 10 places... et 5 seulement affichees** (les 10 plus recentes en comptaient 5 hors ligne) ; cas limite : les 10 plus recentes toutes hors ligne = liste **vide** sous un badge non nul, soit exactement le symptome « j'ai des favoris et je ne les vois pas ». Corrige par `takeVisibleInOrder()` (filtre d'abord, fenetre ensuite, dedoublonnage) + `sortAdvertsByFlxOrder()` (la requete `whereIn` n'ordonne pas : sans ce tri la liste ne respectait pas l'anciennete du favori). Cout inchange : 2 requetes par section, comme avant. ⚠ **Piege de type** : `array_keys()` sur un tableau indexe par les `flx_id` **convertit les cles numeriques en entiers** - inoffensif ici (PostgreSQL et le tri normalisent), mais le helper accumule desormais les valeurs d'origine pour rester type-stable (`flx_id` est un `character varying`, parfois un UUID, parfois un nombre). **0 migration, 0 route, 0 fichier de core** (donc aucun tag, aucun `composer update`). **Verifie** : harness **21/21** qui **reproduit d'abord les deux defauts** avec l'algorithme d'origine (`.insights/dashboard-adverts-tabs-2026-08-24.php`) ; **E2E 12/12** sur la vraie route en session reelle (`.insights/dashboard-adverts-tabs-e2e-2026-08-24.sh`) - un compte « favoris sans localisee » est fabrique puis supprime, avec **contre-epreuve** « localisee sans favori » (onglet Localisees toujours actif) ; non-regression sur un compte portant les deux : onglet inchange et **10 cartes localisees au lieu de 5** ; navigateur : bloc rendu, bascule d'onglet au clic dans les deux sens, **0 erreur console**, 0 ligne de log. ⚠ Le compte observe dans le navigateur (214307) presentait exactement le cas signale - 1 favori, 0 localisee - et ouvre desormais sur « Favoris ».
- **Picker de contact unifié : le carnet d'adresses enfin accessible depuis toutes les actions du lead (2026-08-07)** : signalé sur le lead 1566 - « j'ouvre `modalAddOffer`, le select `ao-offer-contact` ne montre que les contacts rattachés au lead, alors que je voudrais ajouter le contact 578 qui est dans mes contacts. On a le problème avec toutes les actions du lead ». **Constat** : les ~17 selects de contact des modales passaient tous par **un seul helper** (`window.loadLeadContacts`, `phase2-actions.js`) interrogeant `GET /acquisition/lead/{id}/contacts`, qui ne renvoie que les pivots `lead_contacts` - le carnet d'adresses de l'agent était **inatteignable**, sauf à quitter la modale, rattacher le contact via « Associer un contact », puis rouvrir. La seule exception était la modale bail (Select2 sur `/contact-options`), qui savait déjà chercher mais **ne rattachait pas** (elle s'en remettait à l'auto-attachement de `generateBail`). **Livré** : (1) `contactOptions` devient l'endpoint **unique** du picker et répond en **groupes Select2** - « Contacts du dossier » (pivots, avec `pivot_id`/`role`) puis « Intéressés par ce bien » (matching, baux) puis « Mes contacts (non rattachés) », avec recherche serveur (`q`) sur les 3 groupes et filtre `require=email|phone` ; (2) nouveau helper **`window.leadContactPicker(leadId, $select, opts)`** (superset de `loadLeadContacts` : mêmes noms d'options + `attachRole`, `role`, `skipPreselect`) qui monte le Select2, applique la présélection métier existante (`resolveLeadContactPreselections`) et, **au choix d'un contact non rattaché, le RATTACHE au dossier** (POST `attachContact`, déjà idempotent depuis le 2026-08-06) avec le rôle de la modale (acquéreur pour une offre, locataire pour une quittance, caution, bailleur…), puis rafraîchit la section Contacts + le KPI strip ; (3) les 17 selects migrés. **0 migration, 0 route nouvelle, 0 fichier de core** (donc aucun tag, aucun `composer update`, aucun `queue:restart`). ⚠ **Le groupe « Mes contacts » est scopé sur l'ACTEUR (`auth()`), pas sur le propriétaire du lead** : c'est le seul périmètre qu'`attachContact` accepte (garde F10 `Contact::where('user_id', $this->user()->id)`) - proposer un contact du propriétaire sur un lead partagé produirait un 403 au rattachement. Les contacts déjà liés restent affichés quel que soit leur propriétaire. ⚠ **La VALEUR d'une option reste l'id du CONTACT** : les endpoints `/contacts/{pivotId}/offers|applications` attendent le PIVOT → lire **`window.leadContactPivotId($select)`**, jamais `$select.val()` ; `inline-contact-creator.js` (mode attach) a été aligné sur ce contrat (il injectait `value = pivot_id`). ⚠ **`templateResult` doit être surchargé** : celui du core ignore `text` et ne sait pas rendre un GROUPE (il lit `repo.title`, absent d'un groupe → libellé vide). ⚠ **Contraste du dropdown** : Select2 met l'option survolée en fond bleu plein + texte blanc, mais le sous-titre (`.lcp-opt-sub` : rôle sur le dossier, e-mail) gardait la couleur secondaire et devenait illisible - corrigé par une règle sur `.select2-results__option--highlighted` / `[aria-selected="true"]` (sous-titre `rgba(255,255,255,.85)`, pastille d'initiales éclaircie). ⚠ Le rattachement a lieu **dès la sélection**, donc il persiste même si la modale est ensuite annulée (choix produit assumé, demandé par l'utilisateur : « si on le choisit on le relie au lead ») ; en cas d'échec du POST la sélection est annulée. **Effet UI** : les branches « aucun candidat rattaché » (submit désactivé) des modales Offre/Demande et l'empty state contacts du bon de visite disparaissent - le picker cherche dans tout le carnet et le bouton « + » crée un contact. **Vérifié** : endpoint en session réelle (2 groupes, recherche `q`, `require=email`, lead d'autrui → 404), rattachement + **idempotence** (2e appel `already_linked:true`), et **E2E navigateur complet** sur le lead 1566 - dropdown à 2 groupes avec avatars, clic sur « Jal elodie » (contact 578, rattaché à aucun lead) → pivot créé en rôle `buyer`, `leadContactPivotId` = pivot, offre de 97 000 € réellement enregistrée sur ce pivot en base ; non-régression de la présélection (message → contact principal + amorce « Bonjour Julien, » ; mandat → vendeur) et de la recherche live ; 0 erreur console, 0 ligne de log ; **données de test intégralement supprimées** (offre, pivot, activités, `contact_buyer_id`).
- **« Reprendre ma conformité personnelle pour l'agence » : 4 colonnes ne se recopiaient pas (2026-08-07)** : signalé après un parcours complet - « je remplis entièrement l'onglet Conformité sans agence, puis je crée une agence en activant le toggle `transfer_personal_notice` ; ça fonctionne mais il manque des informations ». La liste `$transferableNoticeKeys` d'`AgencyController` était **écrite en dur DEUX fois** (create + convertTeamToAgency, avec le commentaire « identique à create() ») et omettait dans les deux : **`type`** (forme juridique), **`garantie_financiere_expires_at`**, **`activity_category`**, **`network_metadata`**. **Ce n'était pas cosmétique** : (a) `type` alimente `{{agent_company_legal_form}}` des documents Hoguet ET le « Nom - SARL » de `MandateRegistry::buildLegalNoticeText` - c'est-à-dire **l'aperçu affiché sous le toggle dans le wizard**, qui promettait donc une donnée non reprise ; en prime, la forme juridique **n'est saisissable nulle part en périmètre agence** (l'onglet Conformité n'a pas ce champ, seul le formulaire legacy `/compte/profil` du core l'expose, et il écrit sur la fiche PERSO) → une fois perdue au transfert, elle l'était **définitivement** ; (b) `garantie_financiere_expires_at` est lue par `MandateRegistry::assertCanGenerateMandate` → sans elle, l'agence générait des mandats avec une **garantie financière expirée sans blocage ni alerte**, et le champ de l'onglet Conformité s'affichait vide. **Livré** : liste unique `AgencyController::TRANSFERABLE_NOTICE_KEYS` (**35 clés** = toutes les colonnes métier de `core.user_legal_notices`) + helper `transferPersonalNotice()` partagé par les 2 routes - elles ne peuvent plus diverger. ⚠ Restent volontairement hors transfert : `name` (le nom commercial vient du formulaire), `status` (les statuts perso ne sont pas des statuts d'agence - `ConformiteController` n'accepte que `agence_societe`/`agence_individuelle`/`syndic_copropriete` en périmètre agence) et les identifiants. **0 migration, 0 route, 0 vue.** Vérifié par un harness E2E qui **reproduit d'abord le défaut** (4 colonnes manquantes sur les 2 routes) puis, après correctif, **24/24** : diff colonne par colonne sur les 35 clés pour la création d'agence ET la transformation d'équipe, contrôle négatif toggle décoché (rien repris, fiche perso non archivée), et **effet métier** (forme juridique présente dans les mentions de l'agence, garantie expirée de nouveau détectée par le registre, catégorie d'activité lisible, onglet Conformité de l'agence rendu avec les valeurs) ; base de dev rendue à l'identique, 0 erreur de log. Harness : `.insights/agency-transfer-notice-2026-08-07.php`. ⚠ **Signalé, NON traité** (décision produit distincte) : forme juridique et capital social ne sont **pas saisissables** dans l'onglet Conformité en périmètre agence - une agence créée SANS transfert ne peut toujours pas les renseigner.
- **🟥 Recherche cadastrale à 8-11 s : la base `city` n'avait AUCUNE statistique PostgreSQL (2026-08-06)** : signalé sur la recherche sauvegardée « test villeneuve » (commune 66227 + `bati_piscine` + `parcel_shape_pool`) - `/filter/search/count` **8 s**, `/filter/search/list` et `/filter/search` **11 s chacun**. **Ce n'est PAS un défaut de code** : la base montée le 2026-08-06 aux volumes de production avait **67 tables sur 68 du schéma `core_city` sans aucune statistique** (`n_live_tup = 0`, ni `last_analyze` ni `last_autoanalyze`) - typique d'une base restaurée sans `ANALYZE` final. Le planner estimait donc `rows=1` à chaque nœud : il **démarrait par les 1 000 995 parcelles de France entière** ayant cette forme de piscine, remontait vers parcelles → multiparcelles → opportunités, et n'appliquait le filtre commune **qu'en tout dernier** (`Rows Removed by Filter: 1` sur 1,1 M boucles). Après `ANALYZE` : le plan démarre sur les **2 951 parcelles de la commune** et la même requête passe de **8,3 s à 6,7 ms**. **Mesuré de bout en bout sur la recherche signalée** : count **8,0 s → 0,12 s**, list **8,5 s → 0,07 s**, geojson **8,6 s → 0,13 s**, soit **×60 à ×100** - avec un résultat **strictement identique** (334 parcelles, 42 items, vérifié sur 3 exécutions). ⚠ **À CONTRÔLER EN PRODUCTION** : script rejouable et idempotent `.insights/analyze-city-db-2026-08-06.sql` (section 1 = contrôle, section 2 = correction, section 4 = plan témoin). `ANALYZE` **ne modifie aucune donnée** (il n'écrit que les statistiques du planner) et ne pose pas de verrou bloquant les lectures ; coût mesuré ~70 s pour les 3 schémas `core_city` + `bdnb` + `core_city_light`, dont 6 s pour les tables de 21 M lignes. ⚠ **Réflexe à garder** : devant une requête lente, comparer `rows=` estimé et `actual rows=` dans `EXPLAIN (ANALYZE)` **avant** de toucher au code - un écart de plusieurs ordres de grandeur signale des statistiques absentes, et aucune réécriture SQL ne rattrape un mauvais ordre de jointure. ⚠ Défaut de conception constaté au passage, **non traité** (aucun gain une fois les stats correctes) : `SearchQueryBuilder::buildBaseQuery` joint **`fr_opportunities` de façon inconditionnelle** (« toujours sur parcel_1 »), même quand aucun filtre opportunité n'est demandé - mesuré à 2,4 s de surcoût **quand les stats manquaient**, négligeable ensuite ; la retirer imposerait de neutraliser `opportunity_published_at` dans le SELECT.
- **`maxResult` 100 000 → 20 000 : le filtre « type DPE » passait 45 s (2026-08-06, soir)** : signalé sur `POST /opportunity/search/list` avec le seul filtre `foncier_opportunity_type=dpe` - **44 s**, et **41 s** pour le compteur. Cause : ce filtre ne retient que **339 936 parcelles sur 94 361 700 (0,36 %)**, or PostgreSQL parcourt `fr_parcel_multiples` par id croissant jusqu'à réunir `maxResult` parcelles distinctes → **35 millions de parcelles parcourues**. Le plafond `maxResult` borne donc directement ce parcours. Abaissé à **20 000** (choix utilisateur parmi 5 000 / 20 000 / 100 000) : **liste 45,9 → 11,3 s**, compteur 41,0 → 10,2 s ; « DPE + classe A » 5,5 → 4,7 s avec son compteur exact **17 896 préservé** ; sans filtre 1,5 → 0,56 s ; terrain 1,8 → 0,54 s ; recherche cadastrale inchangée (334 résultats, 0,36 s). ⚠ **Effet visible assumé** : le compteur plafonne à 20 000 - une recherche cadastrale large (Perpignan, 43 694 parcelles) affiche désormais **20 000** au lieu de 43 694, et le défilement s'arrête au même seuil. L'affichage ne signale pas ce plafond (comportement pré-existant, il valait 100 000) : ajouter un « 20 000+ » reste à faire si souhaité. ⚠ **Exports NON concernés** (quotas propres : CSV 500, PDF 50), API v1 non plus (`setMaxResult(100)`). **Front recalé dans la foulée** : le seuil `100000` existait aussi côté interface - **pas comme une borne fonctionnelle** (vérifié : aucune limite de pagination ni de scroll), mais comme **échelle de couleurs de l'indicateur de résultats**, en 3 endroits (`resources/js/search.js` ×2 + `resources/views/history/partials/history_action_button.blade.php`). Avec un plafond à 20 000, ses deux derniers paliers devenaient **inatteignables** : l'indicateur ne pouvait plus passer en rouge et le plafond atteint s'affichait en jaune, sans signaler qu'il manquait des résultats. La valeur n'est plus écrite en dur : `AppServiceProvider` la lit sur le trait (`View::share('searchMaxResult', ...)`, helper `getSearchMaxResult()` via classe anonyme), `vendor.blade.php` l'expose en **`window.SEARCH_MAX_RESULT`**, et l'échelle est dérivée du plafond (`searchCounterStatusClass()` : ≤50 vert · ≤500 primary · ≤25 % info · <75 % warning · <100 % warning-dark · **≥ plafond danger**). Vérifié : avec 20 000 tous les paliers sont atteignables, et en remettant 100 000 l'échelle se recale seule. **Le compteur affiche désormais « 20 000+ »** quand le plafond est atteint (`updateResultCounter`), au lieu d'un nombre exact qui ne l'est pas. ⚠ **Toute évolution de `maxResult` est donc suivie automatiquement par l'UI** - ne pas réintroduire de seuil en dur. ⚠ Le filtre « type DPE » reste le cas le plus lourd (**11 s**) : c'est le compromis accepté ; descendre à 5 000 l'aurait mis à 2,8 s mais aurait plafonné le compteur de « DPE + classe A » à 5 000 au lieu de 17 896. Détail des plans et pistes écartées : `core-city/CLAUDE.md`.
- **Menu Opportunités : 22-32 s → ~1,7 s, par les statistiques PostgreSQL (2026-08-06)** : `POST /opportunity/search/list` mettait **22 à 32 s** et `/opportunity/count` **12 s** pour 42 résultats affichés. **Cause réelle : l'absence totale de statistiques PostgreSQL** sur la base `city` (puce précédente) - après `ANALYZE`, le code d'origine passe à **1,71 s** (liste) et **1,72 s** (compteur), sans aucune modification applicative. ⚠ **Une optimisation de tri avait été livrée puis ANNULÉE le même jour** : elle descendait le `LIMIT maxResult` dans la requête interne de `HasSearch` (1,71 → 1,18 s) mais **tronquait les résultats** dès que les jointures dupliquent les lignes - sur le filtre « opportunité DPE + classe A », 17 896 résultats tombaient à 3 879. Détail et garde-fous : `core-city/CLAUDE.md`. **Ce qui reste livré côté site** : les compteurs « DPE » / « foncier » de la toolbar, qui ne dépendent d'**aucun** filtre utilisateur, étaient recalculés par un `SUM(CASE...)` sur ~21 M lignes **à chaque chargement de page ET à chaque `/count`** (**619 ms mesurés par appel**) ; ils sont factorisés dans `OpportunityController::opportunityTypeCounts()` avec un `Cache::remember` de 30 min (la table n'est réalimentée que par le batch hebdomadaire, cf. le badge « Actualisation : semaine du... »). Aucun impact sur les résultats. **État mesuré après correction** : liste sans filtre **1,71 s**, compteur **1,72 s**, filtre « DPE + classe A » liste **4,29 s** / compteur **2,36 s** (17 896 résultats), page `/opportunite` 0,35 s. ⚠ **Le filtre « DPE + classe A » reste à ~4 s** : la jointure `fr_opportunities` × `fr_dpe_maps` produit **1 799 404 lignes pour 17 896 parcelles** (×100 de duplication). Deux pistes mesurées, **non appliquées** : un index couvrant `fr_dpes(etiquette_dpe, id)` (−12 %, créé en dev, sans risque) et le remplacement de la jointure DPE par une jointure dédupliquée (`JOIN (SELECT DISTINCT parcel_id ...)`, **−29 % avec résultat prouvé identique** : 17 896 = 17 896) - cette seconde piste exige de vérifier qu'aucune colonne DPE n'est projetée ni utilisée par le scoring indicatif. ⚠ **Le compteur affiché est un PLAFOND** (`maxResult` = 100 000), pas un total : sans filtre il vaut 100 000 alors que 7,5 M parcelles portent une opportunité active. Comportement d'origine, inchangé. ⚠ **Le tri de la toolbar est ignoré sans filtre géographique** (`search()` force `opportunity_published_at desc`) - pré-existant. ⚠ Observation pré-existante, non traitée : sur `/filter/search/list`, rejouer le curseur `data-page-next` renvoie la même page (vérifié identique avec le fichier d'origine, donc sans lien avec ces travaux).
- **Rattacher un contact déjà présent avec le même rôle : 500 → succès idempotent (2026-08-06)** : erreur de production `SQLSTATE[23505] ... lead_contacts_lead_id_contact_id_role_unique`. **Scénario reproductible en 3 clics** : la modale « Associer un contact » (`#modalAddLeadContact`) alimente son picker via `/contacts/select`, qui listait **tous** les contacts de l'utilisateur **sans indiquer lesquels étaient déjà rattachés**, et le rôle est **pré-rempli au rôle du type de projet** (`seller` pour un lead vendeur) - exactement celui sous lequel le contact principal est déjà lié. L'agent resélectionne son contact, laisse le rôle par défaut, clique « Ajouter » → 500 (cohérent avec la trace : `role=seller`, `is_primary=0`). **Correctif serveur dans le core** (`HasLeadHub::attachContactToLead` idempotent + effet de bord `is_primary` : cf. `core-city-user/CLAUDE.md`). **Côté site** : (1) `AcquisitionController::attachContact` répond désormais **200 avec `already_linked: true` + `message`** (« Ce contact est déjà rattaché à ce dossier en tant que Propriétaire vendeur. ») au lieu d'un 500 - la demande réelle de l'agent (passage en principal, note) est bien appliquée, mais l'UI n'annonce pas un ajout ; (2) `ContactController::select` accepte un **`?lead_id=`** optionnel et expose `linked_roles` (`{role: libellé}`) par contact, résolu par `resolveLeadLinkedRoles()` derrière le **même prédicat `Lead::sharedFor`** que l'ouverture de la fiche (aucun droit nouveau ; un lead d'autrui renvoie un tableau vide) ; (3) le picker (`initLcContactSelect`, `lead-actions.js`) annote les options « Déjà rattaché : Vendeur, Garant » via un `templateResult` custom, et un encart `#lcRoleLinkedHint` prévient **avant** la soumission quand le rôle sélectionné est déjà porté (resynchronisé au changement de rôle). ⚠ **Ne JAMAIS exclure du picker les contacts déjà rattachés** : un même contact porte légitimement deux rôles sur un dossier (vendeur + garant - mesuré : 1 couple sur 165 pivots en dev) ; seul le triplet `(lead, contact, rôle)` est unique. ⚠ **Le `templateResult` du core ignore `text` pour `contact-list`** (avatar + titre seulement) → le rendu annoté est passé en `customParams` (que `filterSelect` applique via `$.extend`, donc il écrase le défaut) ; le `lead_id` voyage **dans l'URL d'init** du Select2, qui est figée à la création de l'instance → toujours ré-initialiser le picker à l'ouverture de la modale (`openAddLeadContactModal` le fait déjà). **0 route, 0 migration.** Vérifié : harness **20/20** (`.insights/lead-contact-duplicate-role-2026-08-06.php`, qui **reproduit d'abord le 23505** avec l'algorithme d'origine), E2E sur la vraie route (1er rattachement `already_linked:false` → 2e identique **200 `already_linked:true`** au lieu de 500 → rôle différent = 2e pivot créé), picker annoté et garde `sharedFor` (lead d'autrui → 0 annotation sur 19 contacts), et navigateur : puce « Déjà rattaché », encart qui apparaît/disparaît au changement de rôle, soumission → toast « déjà rattaché » **sans doublon en base**, 0 erreur console. ⚠ `app.js` n'a pas de hash en dev + cache `immutable` → **hard-reload obligatoire** après `npm run dev`, sinon le picker sert l'ancien rendu (piège rencontré : les initiales de repli du core ressemblent au nouveau rendu).
- **Carte upsell CRM refaite + KPI strip débarrassé des cartes sans objet (2026-08-06)** : signalé sur un compte **Premium en mode `prestataire_services`** (lead de devis, sans type de projet) - « le bloc "Pilotez ce dossier de bout en bout avec le forfait CRM" n'est pas joli du tout, tout est décalé » et « on affiche le KPI Score matching alors qu'on n'a pas l'onglet Matching ». **(1) Le bloc CRM était cassé, pas juste inesthétique** : `_lead_crm_upsell.blade.php` réutilisait les classes globales `promo-*` de `common.scss`, qui décrivent une **page promo en colonne** (bandeau, puis `.promo-cta-section`, puis CTA pleine largeur). Placé DANS le flex `.promo-premium-banner`, le `.promo-cta-btn { width: 100% }` réclamait toute la largeur et écrasait `.promo-premium-content` (`flex:1` + `min-width:0`) : **mesuré en navigateur, contenu à 0 px de large, titre rendu un mot par ligne, carte de 596 px de haut**, CTA à 698 px sur 820. Aucune erreur, aucun débordement - d'où le diagnostic « décalé ». La carte a désormais son propre style **`.ld-crm-upsell` / `.lcu-*`** (`_lead-redesign.scss`, à côté du Hero NBA qu'elle remplace) : mêmes marges `12px 16px 0` que le Hero et que le padding latéral du KPI strip (elle n'était alignée sur rien : `margin: 0 0 1rem` en style inline), badge couronne, label « Forfait CRM », titre, description, 4 bénéfices à coche, CTA compact à droite qui passe pleine largeur sous 768 px. Après : **162 px de haut, contenu 549 px, CTA 201 px**. ⚠ **Les 8 autres consommateurs de `promo-premium-banner` (market, opportunity, `_plan_teaser`, advert ×2, parcel, filter, style) ne sont PAS concernés** : vérifié, tous placent leur CTA dans une `.promo-cta-section` séparée - le défaut était propre à cette carte. **(2) Le pitch est adapté au métier** : il vantait « matching et offres, portail client » à un artisan, or le tier `prestataire` n'a ni `matching` ni `offers` dans `config/lead_capabilities.php`. Les 4 bénéfices et la description sont dérivés de **`lead_capability_tier()`** (variantes `prestataire` / `notaire` / `particulier` / défaut immobilier) - ⚠ **et non de `lead_capability()`** : `HasAgency::hasLeadCapability` renvoie **false pour TOUTES les clés** quand `ACCESS_CRM` est absent (c'est justement le cas ici), il est donc impossible d'en déduire ce que l'abonnement débloquerait. **(3) KPI strip** : la carte « Score matching » n'est plus rendue que si `($isBuyer || $isTenant) && lead_capability('matching')` - **le même prédicat que l'onglet Matching** (`$capMatching`, `lead_detail.blade.php`). Sans ce gate elle affichait un score dont la destination n'existe pas (tier prestataire/notaire **ou** tout plan sans ACCESS_CRM), et sur un lead non typé elle affichait invariablement « Aucun bien » puisque `Lead::matching_score_meta` est BUYER/TENANT only. Le `fetch /matching-score` n'est plus émis quand la carte est masquée. La carte « Budget / Capacité location » suit le même raisonnement (`$isBuyer || $isTenant` : un lead sans type n'a pas de budget par construction). **(4) Grille adaptative** : le partial pose `style="--lkc-cols: N"` (= cartes réellement rendues) et le SCSS fait `repeat(var(--lkc-cols, 4), minmax(0, 1fr))` - la grille figée à 4 laissait **déjà** un trou avant ce lot dès qu'une carte était masquée (ex. vendeur sans capacité `fees`). Résultat : prestataire non typé = 2 cartes (Dernière activité + Contact), vendeur CRM = 4, acheteur CRM = 4. **Le bouton « Définir le type de projet » est conservé tel quel** (quick actions + empty state de l'onglet Pièces), en attendant le futur type « Prospect » demandé pour les prestataires. **0 route, 0 migration, 0 fichier de core** (donc pas de tag ni de `composer update`) ; rebuild `npm run dev` + `view:clear`. **Vérifié** : harness `.insights/crm-upsell-kpi-2026-08-06.php` **43/43** (pitch par tier, absence de classe `promo-*`, présence/absence de chaque carte sur 5 profils, `--lkc-cols` == nombre de `.ld-kpi-card`) ; **fiche complète rendue côté serveur** pour le compte signalant (141 Ko, 1 carte upsell, 0 classe `promo-*`, 2 cartes KPI, 0 carte et 0 onglet matching, bouton « Définir le type de projet » présent, 0 tiret cadratin) et non-régression sur un compte CRM (vendeur 4 cartes + Hero NBA, acheteur 4 cartes dont matching, non typé 2 cartes) ; **mesures navigateur** avec le CSS compilé (hauteurs et largeurs ci-dessus, grille `429px 429px`, aucun débordement horizontal à 380 px), 0 erreur de log.
- **`MatchTrackerController` supprimé (2026-08-06)** : la route `GET /portal/match-tracker/{matchToken}/open` (« Phase 3 - A4 », tracking d'ouverture des biens proposés depuis e-mail/SMS) et son controller sont **retirés**. C'était du **code mort à quatre niveaux** : (1) déclarée dans le groupe `portal.token` mais avec un paramètre nommé `{matchToken}`, elle faisait échouer `ValidatePortalToken` (`$request->route('token')` = null → `abort(404)`) **avant** d'atteindre le controller - vérifié, 404 ; (2) **aucun** code ni template ne générait ce lien (0 appel à `route('portal.matchTracker.open')`, 0 occurrence dans les ~100 templates en base) ; (3) le modèle `Rootsud\City\User\Models\Lead_advert` qu'elle manipulait **n'existe nulle part** (`class_exists` = false) - l'erreur aurait été avalée par son propre `try/catch` ; (4) la donnée collectée (`metadata['email_opens']`) n'était lue par aucun écran ni KPI. **Le tracking réel des matches existe et fonctionne** : `POST /client/{token}/match/{matchId}/click` → `ClientPortalPublicController::trackMatchClick`, qui écrit `seen_at`/`clicked_at` sur `lead_proposed_matches` (route conservée, dans le bon groupe avec le bon paramètre). ⚠ La note de `core-city-user/CLAUDE.md` qui invitait à « activer le tracking via `MatchTrackerController::open` » a été rectifiée - elle envoyait sur une fausse piste. Un exemplaire du fichier supprimé est conservé dans le scratchpad de session. Vérifié après suppression : `route:list` sans `match-tracker`, `/client/{token}` toujours en 302 vers `/verify` sans session, `/client/{token}/verify` et `/portail/cgu` en 200, 0 référence résiduelle, 0 erreur de log.
- **Bouton « Partager » du portail client RETIRÉ (2026-08-06)** : `client_portal/partials/_footer.blade.php` proposait un bouton `[data-cp-share]` qui diffusait **`window.location.href`, c'est-à-dire le lien d'accès secret au dossier**, via la Web Share API (WhatsApp, SMS, mail, AirDrop…) ou le presse-papier. **Contradiction frontale avec les CGU du portail** (§2 : « Il est personnel et confidentiel - vous vous engagez à ne pas le partager avec un tiers non autorisé ») : le produit offrait un raccourci vers ce que son propre contrat interdit. ⚠ **Le danger est maximal quand la vérification d'identité est DÉSACTIVÉE, pas quand elle est active** : avec `requires_2fa`, un tiers qui reçoit le lien tombe sur l'écran de code et le code part **au contact du dossier**, donc l'accès est refusé ; sans 2FA, le lien seul suffit et le destinataire obtient un accès **complet et permanent** (documents, estimation, pièces du dossier ALUR, offres, coordonnées). Mesuré en dev : **43 tokens actifs sur 63 (68 %) ont `requires_2fa = false`** - dont tous les tokens de prospection, créés `false` par construction. Le seul garde-fou existant était **a posteriori** (activité de timeline « Lien portail partagé »), jamais bloquant. **Livré** : bouton retiré du footer + handler retiré de `client_portal.js`. **Conservés volontairement** : `Lead_visit::TYPE_LINK_SHARE`, son libellé de timeline et son entrée dans `$allowedTypes` de `markEvent` - les partages déjà enregistrés doivent rester lisibles dans l'historique des dossiers. **Voie légitime pour donner accès à un tiers** (conjoint, co-acquéreur, garant) : le **rattacher comme contact du lead**, ce qui lui vaut son propre lien nominatif et sa propre vérification - le portail gère déjà co-locataires et garants. Le multi-appareil reste possible par simple copier-coller de l'URL. **Corrigé au passage** : le footer affirmait « Vos visites sont enregistrées de manière **anonyme** » (même erreur que les CGU) alors qu'elles sont rattachées au dossier, seule l'IP étant hashée. Vérifié en navigateur : footer rendu avec le seul bouton « Écrire à mon conseiller », 0 occurrence `data-cp-share` dans le HTML et 0 handler dans le bundle.
- **Pages légales du portail : identité de l'éditeur fausse sur 8 points (2026-08-06)** : `/portail/{mentions-legales,cgu,cookies-rgpd}` publiaient une identité société **inventée et incohérente avec le site infoparcelle.fr**. Écarts corrigés (référence = `rs_infoparcelle_vitrine/resources/views/pages/legal/{cgu,confidentialite}.blade.php`, en production depuis le 2026-08-02) : capital **1 000 € → 2 400 €** · RCS Perpignan **901 524 738 → 888 759 982** · TVA **« FR XX 901524738 » → FR 24 888759982** (le « XX » était un placeholder jamais renseigné) · siège **9 rue de Béziers → 13 Boulevard du Conflent** · directeur de la publication **« M. Julio Soares, président » → Julien Villegas** · contact **contact@rootsud.com → contact@infoparcelle.fr** (+ 09 72 16 74 63, valeurs lues dans `core.project_site_setups`, comme la vitrine). **🟥 `dpo@rootsud.com` supprimé** : aucun délégué à la protection des données n'est désigné et cette boîte n'existe pas - annoncer un DPO fictif rend le **droit d'accès inexerçable** (le courrier part dans le vide) ; remplacé par « contact confidentialité » + adresse postale, formulation de la vitrine. **3 affirmations démenties par le code** : (a) journaux de visite annoncés **« conservés 12 mois »** alors que `leads:cleanup-old-visits` purge à **90 jours** (`tracking.retention_days`, planifiée chaque dimanche 2h dans `routes/console.php`) - corrigé aux 2 endroits ; (b) le **cookie `pv_`** (mémorise qu'un appareil a validé le code de sécurité, jusqu'à 90 j) n'était **pas listé** alors que la page affirme lister tous les cousins déposés - ajouté ; (c) CGU : « visites enregistrées de manière **anonyme** » était faux (elles sont rattachées au dossier, seule l'IP est hashée SHA-256) - reformulé. **Livré** : constante `ClientPortalLegalController::EDITOR` + variables `contactEmail`/`contactPhone`/`visitRetentionDays`/`portalLinkDays` passées aux 3 vues → **une seule source, les 3 pages ne peuvent plus diverger**, et les durées suivent la config au lieu d'être écrites en dur. ⚠ Ne pas confondre `tracking.retention_days` (purge des visites) et `Client_portal_token::DEFAULT_EXPIRY_DAYS` (durée du lien et plafond du cookie) : les deux valent 90, la coïncidence numérique masque deux notions distinctes. ⚠ **Toute évolution de l'identité (capital, siège, dirigeant) doit être répercutée EN MÊME TEMPS sur la vitrine.** Vérifié : 3 pages en 200 avec et sans `?token=` (branding agence), valeurs rendues conformes, **0 occurrence résiduelle** des anciennes valeurs, 0 erreur de log. **Contrôlé mais NON modifié** (exact) : hébergeur OVH SAS Roubaix, hashage IP SHA-256, expiration glissante 90 j, token de 64 caractères, boutons « Exercer mes droits RGPD » et « Je ne souhaite plus accéder à ce dossier » (les deux existent bien dans `_footer.blade.php`).
- **Mode aperçu conseiller du portail client (2026-08-06)** : le bouton « ouvrir dans un nouvel onglet » de la card Espace client (`_lead_portal_card.blade.php`) butait sur la **vérification d'identité** : le code part au contact du lead, auquel le conseiller n'a pas accès - le seul contournement était de **désactiver `requires_2fa` sur le dossier d'un client réel**. Désormais, un utilisateur **connecté qui a accès au lead** ouvre l'espace **sans OTP, en LECTURE SEULE**. **Invariant de sécurité** : l'aperçu est autorisé **si et seulement si la fiche lead est ouvrable**, via le MÊME prédicat `Lead::sharedFor(auth()->id())` que `AcquisitionController::detail` - aucun droit nouveau, le portail est un **sous-ensemble strict** de ce que l'agent voit déjà (la 2FA protège contre la fuite du lien à un tiers, pas contre le porteur du dossier). `requires_2fa` n'est jamais modifié. **Le vrai risque n'était pas la lecture mais la POLLUTION DE DONNÉES** - sans garde, la visite du conseiller aurait produit de faux signaux client : `Lead_visit`, `access_count`/`view_count`/`last_accessed_at`, activité de timeline « Première consultation du dossier client », **prolongation du lien du client** (`extendExpiryIfNeeded`, sliding expiration), « document téléchargé », et des actions signées de son nom (message, RSVP, offre, signature, abonnement push). **Livré, 100 % site (0 migration, 0 fichier de core → aucun tag, aucun `composer update`, aucun `queue:restart`)** : (1) `ValidatePortalToken` pose l'attribut `PREVIEW_ATTRIBUTE`, court-circuite le gate 2FA et refuse tout non-GET en **403 `preview_mode`** - placé **APRÈS le gate de plan, qui reste prioritaire** (un propriétaire sans `ACCESS_PORTAL` renvoie toujours 410, l'aperçu ne passe pas devant) ; traçabilité par `Log::info('portal_preview')` **throttlé par session et par token**, et **aucune `Lead_activity`** (la timeline de la fiche ne doit pas se remplir des consultations internes) ; (2) `TrackPortalVisit` sort immédiatement ; (3) `show()` passe `isPreview` (bandeau + `data-preview="1"` + `CP_CONFIG.preview`, overlay de bienvenue neutralisé) et `downloadDocument()` sert le fichier sans l'enregistrer ; (4) bandeau `.cp-preview-banner` **fixé en bas** + verrou `fetch`/submit dans le layout + coupure heartbeat/event dans `client_portal.js`. **3 pièges rencontrés** : (a) le verrou navigateur bloquait d'abord **tous** les non-GET → la **vue 3D du bien disparaissait** (`client_portal_3d.js` POSTe vers `/city/parcel`, qui est une **lecture**) : il ne vise plus que `/client/...`, comme le serveur ; (b) `client_portal.js` sortait entièrement sur `if (!TOKEN || !VISIT_ID) return` - or en aperçu il n'y a **volontairement pas de visite**, donc `visitId = 0` : sans l'ajustement `(!VISIT_ID && !IS_PREVIEW)`, l'aperçu s'affichait inerte ; (c) `client_portal.css` n'a pas de hash → **hard-reload obligatoire** pour voir le bandeau stylé. **Vérifié** - en base sur un dossier réel : `access_count`/`view_count`/`last_accessed_at`/`expires_at` **strictement identiques** avant/après, 0 `lead_visit`, 0 activité créée, 0 message ; en HTTP : conseiller connecté **200 sans OTP**, **même URL sans session → 302 vers `/verify` (parcours client inchangé)**, POST heartbeat **403 `preview_mode`**, utilisateur connecté non rattaché **→ 2FA maintenue**, assigné via `lead_assignees` **→ aperçu accordé**, plan sans `ACCESS_PORTAL` **→ 410** ; en navigateur : bandeau lisible, **aucune requête réseau en 40 s** (l'intervalle heartbeat est de 30 s), CTA « Envoyer le message » → toast + rien envoyé, vue 3D rendue, 0 erreur console. Données de test nettoyées. **Le lien client reste inchangé** : « Copier le lien » sert toujours le parcours réel avec vérification d'identité.
- **5e widget embarquable : « Demande de devis » (2026-08-05)** : premier widget non immobilier, il ouvre la plateforme au mode pro `prestataire_services` (deja entierement cable : tier de capacites `prestataire`, pipeline Prospect → Contacte → RDV technique → RDV realise, 36 categories d'activite) qui n'avait **aucun** widget. Couvre aussi les prestations immobilieres non transactionnelles (conciergerie, etat des lieux pour un bailleur a distance). Parcours public en 4 etapes : **Votre besoin** (cartes issues des prestations configurees par le pro) → **Adresse du chantier** (autocomplete BAN, resolue en parcelle cadastrale) → **Precisez votre demande** (zone libre **FACULTATIVE** : aucune longueur minimale, etiquetee « (facultatif) » - un prospect qui n'a rien a ajouter n'est pas bloque ; sans texte, `client_message` n'est pas ecrit et le bloc « Commentaire client » de la fiche lead n'est pas rendu) → **Coordonnees** (consentement **PRE-COCHE** + honeypot). Ecran final = confirmation (aucun calcul a restituer, contrairement aux 4 autres). ⚠ **Les prestations s'affichent en LISTE VERTICALE, une par ligne** : les libelles etant saisis librement par le pro, la grille `auto-fit` initiale produisait un dernier element etire seul sur sa ligne et des hauteurs inegales des qu'un libelle passait sur 2 lignes ; `.ip-dv-service-card` neutralise en plus le `transform:scale(.97)` et l'animation de `.ip-w-type-card--selected` (concus pour 2 grosses cartes cote a cote, ils retrecissent visiblement la ligne choisie). **La capture vit dans core-city** (`WidgetDevisController`, lead NON type - voir `core-city/CLAUDE.md`). **Cote site** : `resources/js/widget/src/devis/` (4 modules : index, engine, ui-builder, experiences — les autres widgets trainent des copies mortes de `fields/`, `helpers.js`, `icons.js` qui n'ont pas ete reproduites) + `rollup.devis.config.mjs` + `npm run build:devis` ; `WidgetScriptController::devis()` + route `GET /widget/devis.js` ; `WidgetController::TYPE_LABELS`/`FORM_VIEWS` ; `widget/_form_devis.blade.php` (derive de `_form_recherche`) avec un **repeteur « Prestations proposees »** ecrit dans `landing_meta.services[]` par `saveConfig` (validation `services` array max 12, fusion non destructive du `landing_meta`, liste vide = cle retiree). **PIEGES rencontres** : (1) la route de creation `POST /widgets/create/{type}` portait une **liste blanche de types codee en dur** qui excluait `devis` → **405 silencieux** ; elle est desormais DERIVEE de `User_widget::TYPES` pour que le prochain type ne retombe pas dedans ; (2) `resources/js/widget-create.js` porte un **second registre `TYPE_META` duplique** de `$widgetTypes` du blade — un type absent d'ICI rend la carte cliquable mais laisse le bouton « Creer le widget » **desactive** (`showTypeDetail` sort tot) ; (3) **`_card.blade.php` avait sa PROPRE cascade d'icones par type** : le devis y etant absent, sa carte s'affichait dans `/widgets` avec l'icone et le bouton « Estimer » d'une Estimation, donc introuvable a l'oeil ; passee en table `$typeMeta` avec repli, et **le libelle du type est desormais toujours affiche** (l'agence destinataire le remplacait des 2 agences). ⚠ **Il existe 4 registres de types independants** (`_card.blade.php`, `_modal_create_widget.blade.php`, `widget-create.js`, `WidgetController`) : en oublier un donne un defaut SILENCIEUX. Une centralisation en `config/widget_types.php` reste a faire. (4) `_form_recherche.blade.php` appelle `route('widgets.recherche.update')` **qui n'existe pas** (repli mort, jamais atteint car l'uuid est toujours genere) — le formulaire devis utilise un repli `null` + garde dans `performAutoSave`. **0 migration.** Verifie en navigateur de bout en bout : creation via la modale reelle (5 cartes, detail, bouton actif), fiche de config, repeteur → `landing_meta` en base, preview remontee, page publique `/w/{slug}` (4 etapes, 3 cartes de prestation, consentement decoche), soumission reelle → lead en base avec parcelle rattachee, QR SVG/PNG, les 2 routes de script, bascule des 3 modes de distribution, 0 erreur console ; donnees de test supprimees.
- **Bloc « Commentaire client » dans la note interne du lead (2026-08-05)** : `acquisition/partials/_lead_note.blade.php` rend, **en LECTURE SEULE et au-dessus du textarea de l'agent**, le message libre ecrit par le prospect depuis le widget devis. Source = **`requirement_data['client_message']`**, jamais `leads.comment` : ce dernier est la note DE L'AGENT (editable, autosauvegardee, presente dans `Lead::$auditFields` donc chaque edition ecrit une ligne de timeline) — y ecrire le message du client l'exposerait a un ecrasement par inadvertance et melangerait deux auteurs. Un tag affiche la prestation choisie (`service_type`). **Rien n'est rendu quand la cle est absente** : la carte reste strictement identique pour tous les leads existants (verifie sur 5 leads reels). Rendu dans l'onglet **Pilotage** uniquement, le seul jamais gate par plan (Communications exige ACCESS_CRM = 2 plans sur 40). Classes `.ld-note-client*` dans `_lead-redesign.scss`. **0 route, 0 migration.**
- **Hero NBA : garde « prestataire » (2026-08-05)** : `lead_nba_skips_immo_qualification()` (`app/Helpers/lead_nba_helpers.php`) neutralise la qualification immobiliere quand `lead_capability_tier() === 'prestataire'`. Sans elle, un lead de devis (non type par construction) se voyait proposer « Definir le type de projet » avec les 4 cartes Vendeur / Acheteur / Bailleur / Locataire — aberrant pour un artisan. Le garde agit a **3 endroits** : `$needsQualification` du resolver, l'etape 1 de `lead_nba_state_ladder()`, et son etape 3 (`lead_requirement_filled`, qui ne serait **jamais** satisfaite sur un dossier de devis et bloquerait le hero a vie). Le prestataire deroule alors l'echelle d'etat sur ce qui le concerne : contact → rendez-vous → portail → suivi. **Non-regression prouvee** : un pro sur un lead non type garde « Definir le type de projet », et les 4 types immobiliers sont inchanges (harness `.insights/widget-devis-2026-08-05/harness-nba.php` **15/15** + suite `LeadNbaResolverTest` **17 tests / 845 assertions**).
- **Photo fraichement uploadée éditable immédiatement (2026-08-05)** : dans la fiche parcelle `#tab-photos` (et, même défaut, dans l'onglet Photos de la fiche lead), une photo qu'on venait d'ajouter n'avait **pas** le bouton « Éditer » (`.btn-photo-edit.btn-photo-edit-trigger` → `#modalPhotoEditor`) - il fallait fermer puis rouvrir la fiche. **Cause** : la carte affichée juste après l'upload est un **clone du template caché** (`#photoTemplate` dans `parcel/card_estimate_tab_photos.blade.php`, `#leadPhotoTemplate` dans `acquisition/partials/_lead_photos_tab.blade.php`), et ces templates ne contenaient pas le bouton - à raison, puisque `photo-editor.js` exige `data-pum-id` **et** `data-pum-url` (l'URL du média, inconnue tant que l'upload n'a pas répondu) et `return` sans eux. Or `EstimationController::uploadPicture` ne renvoyait que `$userMedia->id` (scalaire). **Livré** : (1) `uploadPicture` répond en **JSON `{media_id, url, caption, has_edits}`** - les 4 call-sites lisent `response.media_id ?? response`, donc format rétro-compatible (`preview-modern.js`, `tab-info.js`, `tab-estim-photo.js`, `tab-lead-photo.js`) ; (2) les 2 templates portent le bouton **masqué** (`d-none`, attributs vides) ; (3) nouveau helper **`window.activatePhotoEditButton($wrapper, pum)`** (`photo-editor.js`, à côté du handler de clic qu'il alimente) renseigne les `data-pum-*` et retire `d-none`, appelé dans le callback de succès des deux flux d'upload. **PIÈGES** : (a) le helper écrit l'attribut **ET** le `.data()` jQuery - une fois `.data('pum-url')` lu, jQuery ignore les mises à jour d'attribut ; (b) il exige `url` non vide, donc un ancien format de réponse ne casse rien (bouton simplement non activé) ; (c) le `src` de la vignette reste la **data-URL locale** de l'aperçu (non remplacé par l'URL serveur, pour éviter un flash) - sans effet sur l'édition, `onSaved` repatche les `img` par `[data-media-id]`. **0 migration.** Vérifié : harness serveur `.insights/photo-upload-edit-button-2026-08-05.php` (9/9, `url === media->item->getUrl()`, photo de test supprimée) + E2E navigateur sur fragments réels (fiche parcelle 66136000EO0306 et fiche lead 1473) - upload réel d'un PNG via `#photoFileInput`/`#leadPhotoFileInput`, bouton présent et renseigné, clic → `#modalPhotoEditor` ouvert sur la bonne image ; 0 erreur console ; photos de test supprimées (media + media_item + sélections).
- **Note interne du lead : `leads.comment` et `leads.url` enfin visibles (2026-08-03)** : retour client après la bascule prospection → acquisition, « je ne vois plus mes commentaires ». Les commentaires et liens des anciens `prospectings` ont bien été migrés (migration core-city-user `2026_02_20_000011`, l.145) dans `leads.comment` / `leads.url`, mais **aucune vue ne les affichait** : `git log -S "lead->comment"` sur `resources/` = **0 commit**, le champ n'a jamais été rendu. `leads.url` avait même une UI (bouton globe dans `_lead_header_labels`, ajouté en `e3aaee0`) **supprimée par `0bdf5f4`**, et son passage dans le `data-prefill` de `_lead_requirement.blade.php:142` était mort (`lead-actions.js` ne poste jamais la clé `url`, donc le `if ($request->has('url'))` d'`updateRequirement` est inerte). Seul l'export CSV (`core-city/Exports/CsvLeadsExport:205`) restituait la donnée. **Facteur décisif du design** : l'onglet Communications est gaté par `$planCrm = plan_access('ACCESS_CRM')` (`lead_detail.blade.php:51`) et **seuls les plans 329 / 332 (CRM 79,90 / 790) portent ACCESS_CRM=1** — les 38 autres (Basique, Premium, Essai, tous les legacy et partenaires) ont `ACCESS_LEAD=1 / ACCESS_CRM=0` ; les 4 routes `activity` sont elles aussi en `planaccess:ACCESS_CRM`. Une note logée uniquement dans Communications (ou convertie en `Lead_activity`) serait donc restée invisible pour l'écrasante majorité du parc, dont très probablement le client à l'origine du ticket. **Livré** : partial `acquisition/partials/_lead_note.blade.php` (`.ld-note-*`) rendu **uniquement dans l'onglet Pilotage** (jamais gaté, onglet par défaut) juste après `_lead_requirement` — un écho lecture seule dans Communications a existé le temps d'une itération puis a été **retiré sur arbitrage utilisateur** (un seul emplacement, pas deux vérités à l'écran ni de synchronisation à maintenir) ; route `PUT /acquisition/lead/{id}/note` → `AcquisitionController::updateLeadNote` **sans `planaccess:`** (hérite d'`ACCESS_LEAD`, même précédent que `PUT .../requirement`), garde `Lead::sharedFor` ; helper `normalizeLeadNoteUrl` (une saisie `www.exemple.fr` devient `https://www.exemple.fr`). **Historique gratuit** : `comment` figure déjà dans `Lead::$auditFields` → chaque édition produit **exactement une** ligne de timeline « Modification : Commentaire » (valeur tronquée à 60 car. par `LogsAudit`), visible dans Communications pour les comptes CRM — d'où l'autosave sur **blur + debounce 1200 ms** (jamais à la frappe) et le garde « valeur inchangée » côté JS, doublé par le `getDirty()` d'Eloquent côté serveur. **0 migration, 0 modification de core** (donc pas de tag ni de `composer update` à orchestrer). **PIÈGES** : (1) handlers **délégués sur `body`** (namespace `.ldnote`) car la fiche est réinjectée en AJAX par `showDetail()` ; (2) la hauteur initiale du textarea (`rows`) est calculée **serveur** pour que la note soit lisible en entier sans init JS (l'auto-grow JS ne prend le relais qu'à la saisie) ; (3) l'auto-grow porte un garde `offsetParent === null` — sur un champ masqué (onglet inactif) `scrollHeight` vaut 0 et poserait un `height:0px` inline qui écraserait le textarea au réaffichage ; (4) en test navigateur, un `el.blur()` ne déclenche rien quand `document.hasFocus()` est faux — c'est le debounce `input` qui sauve. **Écarté sur arbitrage utilisateur** : rétablir le globe URL du header et afficher un aperçu du commentaire sur les cards liste/kanban. Vérifié E2E : compte **Basique 327** (sans ACCESS_CRM) → carte visible et éditable, PUT **200** alors que `POST .../activity` renvoie **432** ; lead partagé (`sharedFor`) éditable, lead d'un autre porteur **404** ; URL sans schéma normalisée ; une seule ligne d'audit par édition, zéro au rejeu identique ; lead A → lead B → 2ᵉ save OK ; lead sans note → repli une ligne et **aucune** carte rendue dans Communications ; 0 erreur console, 0 erreur dans les logs.
- **Déploiement prospection vers acquisition : garantie de pipeline (2026-07-30)** : préparation du passage en prod (la prod tourne encore l'ancien menu Prospection, le déploiement jouera tout le retard de migrations d'un coup). Le pipeline **ne dépend pas** de la modale d'onboarding : 3 filets indépendants existent - le backfill `2026_03_10_000004`, le lazy-seed `Lead_status::effectiveQuery()` (cascade perso > agence > `seedDefaults`) et la projection `Lead_status::projectStatusForViewer()` qui envoie tout lead à `lead_status_id` NULL/orphelin sur la **1re colonne active de son propriétaire** (le bucket « 📥 Reçus » ne concerne que les leads d'autres users). `#modalWelcomeOnboarding` ne fait que **remplacer** le pipeline par défaut (`archive_existing:1` puis `PipelinePresetService::remapPerimeterLeads`, qui rattrape déjà les `lead_status_id` NULL). Le trou réel était ailleurs : le backfill filtre `isProspect = false AND isDeleted = false`, donc un porteur de prospectings marqué prospect perdait son statut legacy. Fermé par la migration **`2026_07_30_000001_ensure_pipeline_for_all_leads`** (core-city-user, idempotente - détail et pièges dans `core-city-user/CLAUDE.md`). **4 livrables dans `.insights/`** : `measure-prospecting-migration-2026-07-30.sql` (mesure prod, lecture seule : volume migrable, prospectings skippés, porteurs exclus du backfill, distribution des statuts, durée estimée), `harness-prospecting-migration-2026-07-30.php` (**31 assertions** : mapping des 6 statuts dont le fix `2 -> base 2`, traçabilité metadata, label + pivot + activité, archivage de la source, **idempotence des leads ET des labels**, non-création de templates système, `down()` ciblé ; jeu de test créé puis intégralement supprimé via `finally`), `verify-pipeline-guarantee-2026-07-30.php` (invariants avant/après, avec compteur séparé pour les **défauts antérieurs** : 42 leads de l'agence 215 posés sur des statuts de l'agence 220, baseline paramétrable via `BASELINE_AGENCY_MISMATCH`), `deploy-prospecting-migration-2026-07-30.md` (runbook prod). **Mesuré en dev** : 4 leads widget à `lead_status_id` NULL remappés sur le statut de base 0 de **leur propre** pipeline (dont un pipeline personnalisé « Prospect identifié »), kanban vérifié colonne par colonne, 0 lead sans statut, 0 template système rattaché. **⚠ `migrate:rollback` est cassé globalement** (`Cannot declare class AddColumnProjectExtrasTable`, défaut préalable du core) : le rollback est interdit sur ce lot, le plan B est la restauration de sauvegarde. **Effet de bord connu et ASSUMÉ** (décision produit 2026-06-08, reconfirmée le 2026-07-30) : seuls 26 comptes sur 20 853 ont un mode pro configuré et 16 ont le flag d'onboarding -> **20 827 utilisateurs** verront `#modalWelcomeOnboarding` s'ouvrir à l'**étape 1 (mode pro), obligatoire et non fermable**, au premier accès post-déploiement. **C'est voulu** : le choix du mode pro est désormais obligatoire (il conditionne Hoguet, les capacités de la fiche lead et les templates). Aucun backfill du flag, aucune dérogation : ne pas « corriger » ce comportement.
- **Hero NBA : statut « gagné » avec une action encore due (E2E 2026-07-28, 5ᵉ passe)** : défaut trouvé en déroulant le funnel de bout en bout dans le navigateur. À l'entrée dans un statut `won` NON clôturé, la branche `$isSelled && !$isClosed` primait sur tout et affichait « Vous avez atteint la fin de ce pipeline / Clôturer la vente » **alors qu'une tâche restait ouverte** (cas réel : « Envoyer l'avis de valeur », dernière action du `qualification_funnel`, dont le statut final « Estimation » est de catégorie won). L'agent était invité à clôturer sans avoir transmis l'avis au client. **Fix** : nouveau flag `$nbaWonPendingAction` (= statut won non clôturé + tâche ouverte + action métier disponible) qui prend la main sur le wording de clôture : titre = la tâche, consigne = sa conduite + « Une fois cette action faite, vous pourrez clôturer le dossier », boutons = **[action métier] + [Marquer fait] + [Clôturer la vente]** (la clôture reste accessible, elle n'est simplement plus l'action mise en avant). Dès la tâche cochée, le hero rebascule sur « Clôturer le suivi de vente » (vérifié). Les 3 autres régimes won (clôturé et ses 8 wordings gradués, won intermédiaire, won terminal) sont **inchangés**. **E2E complet des 5 modèles** (compte de test vierge `testdemo@test.fr` 214307, plan CRM activé pour être représentatif via `.insights/nba-audit-2026-07-28/grant_crm_plan_testdemo.php` - script DEV-only, idempotent, `--revoke` pour retirer) : chaque pipeline appliqué via l'endpoint réel puis déroulé sur un lead NEUF (contact → type → critères → estimation → …). Résultats : `qualification_funnel` vendeur va jusqu'à la clôture ; `vente_classique` À contacter → Estimation → Avis de valeur ; `location_alur` bailleur → « Planifier un rendez-vous » (prérequis de « Saisir le compte rendu ») ; `chasseur_immobilier` acheteur → « Sélectionner des biens » ; `transactions_mixtes` locataire → « Définir loyer max et secteur » puis « Planifier un rendez-vous ». **0 action hors-type, 0 prérequis manquant** sur les 5. Données de test intégralement supprimées (leads, tâches, activités, contacts, estimations - contrôle à 0). **PIÈGE E2E** : `apply-preset` avec `archive_existing:1` **remappe les leads existants** vers le nouveau pipeline → relire un lead APRÈS avoir appliqué un autre modèle donne un statut faussé ; ne comparer que les relevés pris pendant le run. **PIÈGE session** : la session ngrok est partagée - `meta[user-id]` peut changer entre deux tests (ici user 2 → 214307), un 404 « Lead non trouvé » sur TOUS les leads en est le symptôme.
- **Modèles de pipeline proposés : 100 % auto-complétés (2026-07-28, 4ᵉ passe)** : exigence produit - tout modèle offert dans `#modalPipelineWizard` **ET** dans `#modalWelcomeOnboarding` doit produire un pipeline entièrement automatisable. **Les 2 modales partagent la même chaîne** : `PipelinePresetService::scenarioCatalog($mode)` → `POST /parametres/pipeline/apply-preset` → `PipelinePresetService::apply()`, et les 2 vues (`setting/automatisation/_preset_wizard` + `dashboard/_onboarding_scenarios`) rendent les 3 mêmes attributs avec les MÊMES fallbacks (`data-scenario-slug`, `data-mandate-types` `?? ['vente']`, `data-niveau-reco` `?? 'standard'`). Seules différences légitimes : l'onboarding poste toujours `entity:'user'` (démarrage perso) et sans webhook CRM. **3 tâches sur 50 n'avaient pas d'`auto_complete_on`, toutes câblées** : « Organiser les visites » (acheteur, statut avis_valeur) → **`visit_completed`** (l'organisation est aboutie quand la visite a eu lieu ; c'est aussi le trigger de sortie du statut, et le pattern existait déjà pour « Confirmer le rendez-vous » du preset location) ; « Préparer la visite » (particulier, ×6 occurrences) → **`visit_completed`** (même logique ; le statut « À visiter » est atteint par `visit_scheduled`, donc l'event n'est pas déjà satisfait à l'entrée) ; « Finaliser le dossier » (particulier mixte, statut Engagé) → **renommée « Demander les pièces au candidat » + `demande_pieces_sent`** (un intitulé fourre-tout n'a aucun signal technique fidèle ; une tâche doit être ATOMIQUE et traçable - capability `docs.request` disponible pour le tier particulier). **RÈGLE** : ne JAMAIS poser un event approximatif pour « faire du chiffre » - une tâche qui se coche sans que le travail soit fait est pire qu'une tâche manuelle. Si aucun event fidèle n'existe, rendre la tâche atomique ou la déclarer dans `$manualTasksAllowed`. **Gate permanent** : `validate_configs.php` refuse (ERREUR) toute tâche d'un niveau supporté sans `auto_complete_on`, hors liste blanche `$manualTasksAllowed` (aujourd'hui VIDE). **Audit dédié** `.insights/nba-audit-2026-07-28/audit_wizard_completeness.php` : pour chaque mode pro × scénario réellement proposé, vérifie 7 critères (statut sans tâche, tâche sans event, event inconnu, statut sans sortie, event sans action dans le hero, condition de trigger inconnue, statut cible du webhook inexistant) → **23 combinaisons, 0 anomalie**, avec le détail statuts/tâches/auto-complétées/triggers par ligne. 17 tests (801 assertions) dont `test_every_offered_scenario_is_fully_automated` et `test_wizard_and_onboarding_offer_the_same_models`.
- **Hero NBA : prérequis métier + pertinence par type de lead (2026-07-28, 3ᵉ passe)** : retour utilisateur « on propose *Envoyer l'avis de valeur* alors que l'estimation n'existe pas ». Deux règles manquaient au resolver. **(1) `lead_nba_prerequisites()`** - table des dépendances DURES (action techniquement impossible ou légalement interdite), résolues **récursivement** dans `lead_nba_resolve_action()` : si un prérequis n'est pas satisfait, c'est la première marche réellement franchissable qui est proposée, avec un hint préfixé « Étape préalable à « X ». ». Contenu : `avis_valeur_sent ← estimation_created` (le cas signalé), `advert_published ← mandat_signed` (art. 6 loi Hoguet : pas de publicité sans mandat écrit), `acte_authentique_signed ← compromis_signed`, `compromis_signed ← compromis_generated`, `bail_signed ← bail_generated`, `etat_lieux_created ← bail_generated`, `offer_accepted ← offer_made`, `matches_sent ← match_proposed`, `match_proposed ← lead_requirement_filled`, `lead_requirement_filled ← lead_qualified`, `visit_completed`/`visit_report_filled ← visit_scheduled`, + `contact_linked` pour tout envoi. **`lettre_prospection_sent` n'a VOLONTAIREMENT aucun prérequis** (prise de contact initiale, avant même qu'un contact soit rattaché). **(2) `lead_nba_event_requirement_types()`** - matrice de pertinence par type : `estimation_created`/`avis_valeur_sent`/`advert_published`/`lettre_prospection_sent`/`contact_selected` = SELLER+LESSOR ; `compromis_*`/`acte_authentique_signed`/`offer_*` = SELLER+BUYER ; `bail_*`/`etat_lieux_created` = LESSOR+TENANT ; le reste = tous types (dont `mandat_signed`, qui couvre AUSSI le mandat de recherche acquéreur/locataire, et `match_proposed`/`matches_sent`, le rapprochement étant bidirectionnel). Sans ce filtre, la déduction par titre et la substitution par prérequis pouvaient proposer « Générer le bail » sur un dossier ACHETEUR ou « État des lieux » sur un VENDEUR (les tâches et triggers du pipeline, eux, portent déjà leur propre `requirement_type`). Un lead **non typé n'est jamais filtré** (le garde-fou de qualification agit avant). **Libellés désambiguïsés** : `compromis_signed` → « Faire signer le compromis », `bail_signed` → « Faire signer le bail » (vs « Préparer le … » pour les `*_generated`). **Validation** : 2 nouveaux critères d'audit - **F4 (action proposée non exécutable) = 0** dans `audit_nba_runtime.php`, et nouveau **`audit_nba_sequence.php`** qui déroule le parcours complet des 4 types et vérifie qu'aucune action n'est proposée hors-type, sans prérequis ou en boucle (0 anomalie sur les 3 pipelines testés). Séquences obtenues : vendeur = contact → coordonnées → type → projet → **estimation → avis → mandat → compromis → annonce → clôture** ; bailleur = … → **estimation → avis → mandat → préparer bail → faire signer → EDL** ; acheteur/locataire = **sans estimation ni avis**, avec offres/matching. 15 tests (638 assertions). **PIPELINES ANCIENS - `auto_complete_on` manquants (constat 2026-07-28, NON corrigé)** : un pipeline appliqué AVANT l'enrichissement du catalogue d'events (`lead_requirement_filled` le 2026-05-27 ; `match_proposed`/`matches_sent`/`visit_report_filled`/`etat_lieux_created`/`offer_made`/`compromis_generated` le 2026-06-09 ; `bail_generated` le 2026-06-10) est figé avec le catalogue de l'époque : ses tâches ne se cochent jamais seules. **Le wizard actuel n'est PAS en cause** (un funnel appliqué aujourd'hui = 14 tâches / 14 avec event, vérifié). En dev : 13 templates concernés sur 3 pipelines - agence 215 (557 « Définir critères et budget », 558 « Préparer l'estimation », 559 « Sélectionner les biens à visiter ») + comptes de test 5584/5587. Script de rattrapage **rejouable et idempotent** : `.insights/nba-audit-2026-07-28/backfill_missing_auto_complete_on.php` (DRY-RUN par défaut, `--apply` pour écrire) - complète uniquement les templates dont l'event est NULL ET dont le titre correspond exactement à une tâche de preset qui en déclare un ; n'écrase jamais un event existant, laisse intactes les tâches manuelles par conception (« Organiser les visites », « Reporting hebdo ») et les tâches personnalisées hors preset. **DÉCISION UTILISATEUR 2026-07-28 : NON APPLIQUÉ** (la déduction par titre du hero propose déjà la bonne action ; les 3 tâches restent à cocher à la main). NB : « Émettre la note d'honoraires » présente sur les statuts won des vieux pipelines est **dormante** (retirée du produit le 2026-06-10, pas de module facturation). **PIÈGE** : la substitution par prérequis s'applique AUSSI aux events venant d'une tâche - si la tâche dit « Envoyer l'avis de valeur » mais qu'aucune estimation n'existe, le titre reste celui de la tâche et le bouton devient « Estimer le bien » (le hint explique le lien). C'est voulu : on ne propose jamais un bouton inopérant.
- **Hero NBA : consigne concrète + action déduite du titre de la tâche (2026-07-28, 2ᵉ passe)** : retour utilisateur « on ne comprend pas ce qu'il faut faire » (le hero affichait « Compléter le projet » sans dire quoi compléter). **3 corrections.** **(1) `hint` par action** : chaque entrée de `lead_nba_action_map()` porte désormais une consigne qui répond à « qu'est-ce que je fais, là, maintenant » (ex. `avis_valeur_sent` → « Transmettez le rapport d'estimation au client par email ou SMS. C'est ce qui déclenche la suite du dossier. »). Ce hint devient le `.lhn-sub` du hero, remplaçant les phrases passe-partout (« Tâche prioritaire à traiter aujourd'hui ») et le jargon (« Cette action satisfait la condition qui manque »). Le message « en retard » **préfixe** désormais la consigne au lieu de l'écraser (savoir qu'on est en retard sans savoir quoi faire n'aide pas). **(2) `lead_nba_requirement_gap(Lead)`** : miroir exact de `checkLeadRequirementFilled`, traduit le projet incomplet en champs nommés selon le type (vendeur → « Renseigner le prix et la surface » + « Il manque le prix de vente souhaité ou la surface habitable » ; acheteur → « Définir budget et secteur » ; etc.). Remplace label ET hint de `lead_requirement_filled`. **(3) `lead_nba_event_from_title()`** : quand le template n'a PAS d'`auto_complete_on` (pipelines d'agence, pipelines antérieurs au câblage des events, tâches créées à la main), l'action est déduite du TITRE de la tâche via ~50 motifs normalisés (sans accents). Sans ça, le hero affichait « Envoyer l'avis de valeur » en titre avec « Marquer fait » comme seul bouton - l'agent lit une consigne sans pouvoir l'exécuter. **Impact mesuré** : sur les 132 leads actifs réels, `task_manual` passe de **98 à 7** (92 tâches basculées en action métier concrète, nouvelle source `task_title`). **ORDRE DES MOTIFS CRITIQUE** (du plus spécifique au plus général, 1er qui matche gagne) : relances en tête (« Relancer le vendeur suite à son offre » = relancer, PAS saisir une offre), « envoyer l'estimation » avant « estimation » (transmettre l'avis ≠ relancer un calcul), matching avant visite (« Sélectionner les biens à visiter » = sélection, PAS un RDV), comptes rendus avant « visite ». **Garde** : un `contact_linked` déduit d'un titre bascule en `proposal_sent` si le lead a DÉJÀ un contact (« Rappeler M. Dupont » ne doit pas proposer « Ajouter un contact »). Les vraies tâches manuelles (« Reporting hebdo vendeur », « Prendre les photos du bien », « Suivi SRU J+10 ») ne déduisent RIEN, par conception. **Validation** : nouveau critère **F3 (action sans consigne) = 0** ajouté à `audit_nba_runtime.php`, vert sur les 4 pipelines (70 combinaisons) ; balayage 132 leads = 100 % ; 12 tests (520 assertions) dont la matrice de déduction par titre et les contrôles négatifs. **PIÈGE** : un `hint` doit expliquer le geste, pas répéter le libellé - le test impose >30 caractères.
- **Niveaux d'automatisation du wizard : seul `standard` est supporté (2026-07-28)** : le sélecteur minimal/standard/maximal a été retiré du produit (le wizard `_preset_wizard.blade.php` et l'onboarding postent un hidden alimenté par le `niveau_recommande` du scénario, et les 8 scénarios déclarent `standard`), mais le serveur acceptait encore les 3 valeurs. Or `minimal` produit un pipeline **inexploitable** : `tasks_by_level` = `array_slice($TASKS, 0, 3)` (tâches sur le seul 1er statut) et `triggers_by_level` réduit à une transition → les 4 statuts suivants n'ont NI tâche NI transition sortante, les leads y restent bloqués (avançables au drag-drop uniquement). Un POST forgé sur `apply-preset`, ou une réactivation future du sélecteur sans revoir les configs, produisait ce pipeline mort. **Source unique** : `PipelinePresetService::SUPPORTED_LEVELS` (= `['standard']`) + `LEGACY_LEVELS` + `DEFAULT_LEVEL` + `normalizeLevel()`. **Verrou HTTP** : les 2 endpoints (`/parametres/pipeline/apply-preset` et `preset-impact`) valident `Rule::in(SUPPORTED_LEVELS)` → **422** sur minimal/maximal/inconnu (vérifié). **Filet non HTTP** : `resolveEffectivePreset` appelle `normalizeLevel()` qui ramène un niveau legacy vers `standard` + `Log::warning` (jamais de pipeline bloqué depuis une commande/un job), et lève `InvalidArgumentException` sur une valeur inconnue. Les entrées `minimal`/`maximal` restent dans `config/pipeline_presets.php` (référence + réouverture future) mais ne sont plus appliquées. **Garde-fou de réouverture** : `.insights/audit-pipeline-wizard-2026-06-10/validate_configs.php` refuse désormais (ERREUR, plus warning) tout niveau de `SUPPORTED_LEVELS` dont un statut actif n'a pas ≥1 tâche ET ≥1 transition sortante, et vérifie que chaque `niveau_recommande` est supporté (sinon le wizard recevrait un 422 opaque). Contrôle négatif : rouvrir `minimal` lèverait **71 violations**. **1 trou réel corrigé au passage** (détecté par la nouvelle assertion) : `particulier/vente_location` niveau standard, statut « Engagé » sans AUCUNE tâche → « Finaliser le dossier » remontée du niveau maximal. Tests : `LeadNbaResolverTest` (9 tests, 439 assertions) couvre les 3 règles. **PIÈGE** : pour rouvrir un niveau, l'ajouter à `SUPPORTED_LEVELS` **puis** lui donner un contenu complet - sinon le harness et le test échouent, par conception.
- **Hero NBA de la fiche lead : resolver à cascade, une action métier garantie (2026-07-28)** : `.ld-hero-nba .lhn-actions` dérivait son bouton UNIQUEMENT de l'`auto_complete_on` de la tâche prioritaire, d'où deux trous structurels mesurés par un audit exhaustif (`.insights/nba-audit-2026-07-28/`) : **F1 = 84** combinaisons (statut × type) sans AUCUNE tâche applicable → « Aucune action planifiée » + « Ajouter une tâche », et **F2 = 140** combinaisons où la tâche en tête n'avait pas d'event mappé → « Marquer fait » seul. Cas signalé : lead 892 (`requirement_type` NULL, statut « Estimation » du preset vente) — `LeadStatusEntryHandler::handle` passe `reqType = 0` et `Lead_task_template::scopeForRequirementType` ne crée que les templates `requirement_type IS NULL` ; or les 2 templates du statut sont typés (SELLER + BUYER) → 0 tâche → hero vide. **Le pipeline étant GLOBAL à l'utilisateur** (`Lead_status::getEffectiveForLead`), les 5 types (dont « sans type », capté par widget/création rapide) peuvent atterrir dans n'importe quel statut : toutes ces combinaisons sont réellement atteignables. **Solution** : nouveau resolver `app/Helpers/lead_nba_helpers.php` (autoload `files`) — `lead_next_best_action(Lead)` applique une cascade **qualify → task_event → task_manual → trigger → ladder → add_task** qui retourne TOUJOURS une action (`{primary, secondary[], task, source}`), avec l'**invariant « Marquer fait » jamais primaire seul**. `lead_nba_action_map()` passe de 19 à **30 events** (ajout de `match_proposed`, `matches_sent`, `visit_completed`, `visit_report_filled`, `contact_selected`, `offer_made`, `offer_accepted`, `etat_lieux_created`, `bail_generated`, `compromis_generated`, `portal_claimed` — toutes les actions UI existaient déjà, seul le câblage manquait), **mémoïsée PAR utilisateur** (le gating dépend du tier ET du plan → un process multi-users ne sert plus la map du précédent). `lead_nba_state_ladder()` fournit le filet métier (type → contact → critères → estimation → avis → matching → RDV → portail). **2 défauts latents corrigés** : (a) `$allTriggers` n'était pas filtré par `requirement_type` alors que `Lead_status_trigger::fire()` l'est → le bandeau « Avancer vers X » annonçait une étape d'une AUTRE branche que le backend n'aurait jamais déclenchée (un lead acheteur au statut « Estimation » voyait la condition vendeur `avis_valeur_sent`) ; (b) la branche « il manque N condition(s) » ne rendait AUCUN bouton alors que `$primaryTrigger->conditions` était déjà calculé → `lead_nba_trigger_action()` propose désormais l'action qui satisfait la 1re condition non remplie. La blade passe de ~700 à 432 lignes et ne garde que le rendu + les états terminaux (abandonné / won non clôturé / won clôturé et ses 8 wordings gradués via `$closureLevel`, **déplacés à l'identique**) ; nouveau partial `_lead_hero_nba_button.blade.php` mutualise les attributs par famille de handler (`data-tab`+`data-scroll-to`, `data-target-modal`, `data-token-id`, `data-cadastre-id`). **Validation** : audit runtime sur 5 pipelines réels (vente, location ALUR, funnel qualification, prestataire, mixte) = **95 combinaisons, F1 = 0 et F2 = 0** (leads fabriqués puis purgés) ; balayage de **132 leads actifs réels** = invariant respecté à 100 %, 0 exception ; 6 tests `tests/Feature/LeadNbaResolverTest.php` (427 assertions) ; E2E navigateur (clic réel → `modalAddLead` étape 1 sur le lead 892 ; `ld-hero-jump-tab` → bascule d'onglet + cible de scroll résolue), 0 erreur console. **PIÈGES** : (1) le garde-fou qualification ne devient primaire QUE sans tâche à traiter — il ne vole pas la vedette au « Ajouter un contact » du premier pas, et reste proposé en secondaire tant que le lead n'est pas typé ; (2) les 34 combinaisons F2 restantes sont TOUTES des tâches `auto_complete_on = null` **volontaires** (relance J+3, reporting hebdo, suivi SRU) : « Marquer fait » y est la bonne action et un raccourci métier est ajouté à côté ; (3) `lead_capability()` intersecte le **plan** (`!hasPlanVariable('ACCESS_CRM')` → toutes capacités false) → sur un compte bridé la map se vide et la cascade bascule sur l'échelle d'état, ce qui est le comportement voulu (jamais de bouton menant à un 403) ; (4) l'assainissement des presets n'a **délibérément PAS** été fait : 66 des 84 combinaisons F1 venaient du niveau `minimal` et 3 de `maximal`, tous deux **inatteignables depuis l'UI** ; sur les 15 restantes (niveau `standard`), 7 sont des leads sans type (le garde-fou de qualification est la bonne réponse, pas une tâche générique) et 8 des types incohérents avec le pipeline (bailleur dans « Vendre un bien ») que l'échelle d'état traite mieux qu'une tâche ajoutée au preset de tous les vendeurs. Le verrou des niveaux (puce ci-dessus) traite la cause réelle.
- **Présélection des parties dans TOUTES les modales de génération (audit 2026-07-27, 3ᵉ passe)** : généralisation de la règle « la contrepartie d'un acte = le candidat qui s'est positionné ». **(1) `preselectOfferCandidate`** (branche de `_pickPreselectContactId`, évaluée avant le rôle, pool restreint aux `preselectRole` demandés) : offre/dossier `accepted` > contact retenu (`lead_contacts.status='selected'`) > **unique** porteur d'offre. Appliqué à `#gcompBuyer` (compromis), `#gblTenantSelect` (bail), `#gcaTenant` (acte de cautionnement), `#qltTenant` (quittance), `.gp-contact-select` **quand le destinataire est le locataire** (congé bailleur). **(2) Garde-fou anti-contradiction sur `preselectPrimary`** : le repli « contact principal » (et « contact unique éligible ») est désormais **ignoré si ce contact porte un rôle de PARTIE contradictoire** (`PARTY_ROLES = seller|lessor|buyer|tenant|co_tenant|guarantor`) avec le rôle attendu — sur un lead bailleur, le bailleur (contact principal) ne peut plus se retrouver présélectionné comme « locataire » d'une quittance/caution ; les rôles neutres (notaire, courtier, `other`…) restent des replis valides. Corrige un défaut latent de TOUS les call-sites `preselectPrimary`. **(3) Modale bail (Select2 `/contact-options`, qui ne dit pas QUI choisir)** : nouveau helper global **`window.resolveLeadContactPreselections(leadId, specs, cb)`** (phase2-actions.js) = **1 seul GET `/contacts`** → résout N présélections (`all:true` = tous les contacts des rôles demandés, pour les selects multiples) ; `gblPreselectContacts` injecte les options (avec `data-email`/`data-phone` pour le garde-fou OTP, `data-name` du select pour l'avatar Select2), **sans jamais écraser un choix déjà fait**. Résultat : bailleur = le bailleur du dossier, **locataire = le candidat de la demande de location**, co-locataires + garants = tous les contacts rattachés avec ce rôle. **(4) Autres ajustements** : `#mntContact` (mandat notarial) reçoit `preselectRole:['seller','lessor']` (il n'avait que `preselectPrimary` → pouvait désigner un acquéreur comme mandant) ; `#gcaLessor`/`#gcaTenant`/`#gblTenantSelect` en rôle STRICT. **NON modifiés à raison** : demande de pièces (`#sdpContactSelect`) — le catalogue `requiredDocumentsCatalog` est piloté par le `requirement_type` DU LEAD (sur un lead bailleur ce sont les pièces `lessor_*`, pas le dossier ALUR du candidat) donc le destinataire par défaut reste le propriétaire ; bon de visite (le visiteur précède l'offre) ; message libre et envoi d'avis (destinataire = contact principal/serveur). **0 route / 0 migration.** E2E : jeu de test bailleur (bailleur principal + 2 candidats + co-locataire + garant) → dossier accepté gagne, puis candidat « retenu » gagne quand aucun dossier n'est accepté, puis **select locataire VIDE** quand aucun locataire n'est rattaché (garde-fou) ; caution + quittance alignées ; matrice synthétique de 7 cas (dont non-régression mandat/bon de visite) ; compromis toujours OK. Données de test supprimées.
- **Select candidat des modales Offre / Demande de location : AJAX-on-open (2026-07-27)** : `#modalAddOffer` (`.ao-offer-contact`) et `#modalAddApplication` (`.aa-contact`) rendaient leur liste de candidats **côté serveur** (`$offerPivots`/`$appPivots` = `$lead->leadContacts` au moment du rendu du fragment `lead_detail`). Tout contact rattaché ENSUITE sans re-render complet de la fiche (icc en mode attach d'une autre modale, portail, `Acquisition.refreshLeadContactSections` qui ne rafraîchit que `[data-section="contacts"]` + kpi-strip) restait **introuvable dans le select** - symptôme utilisateur « le contact est bien dans le lead mais je ne peux pas le sélectionner ». Les 2 modales rechargent désormais leurs candidats **à chaque ouverture** via `window.loadLeadContacts` (pattern AJAX-on-open du Sprint 2026-05-27), sur un handler délégué `click.aoOpenOffer`/`click.aaOpenApp` posé sur `.lhn-trigger-modal[data-target-modal="#modalAddOffer|#modalAddApplication"]` qui appelle d'abord `window.promoteFicheModal(sel)` (idempotent → cible la MÊME copie que le handler générique, quel que soit l'ordre de binding). **`loadLeadContacts` étendu** (`phase2-actions.js`, options opt-in, rétro-compat vérifiée) : **`usePivotId`** (valeur de l'option = `lead_contacts.id` et non `contacts.id` - requis par les endpoints `/acquisition/lead/{id}/contacts/{pivotId}/offers|applications`), **`hideMeta`** (pas de suffixe email/téléphone), + attributs `data-contact-id`/`data-pivot-id` sur chaque option. **Présélection métier** : offres → `preselectRole:['buyer','tenant']`, demandes → `['tenant','co_tenant']` (jamais le vendeur par défaut, avec placeholder « Choisir un candidat... » en tête). **Compromis (2026-07-27, 2ᵉ passe)** : `#gcompBuyer` reçoit `preselectOfferCandidate:true` — nouvelle branche de `_pickPreselectContactId` évaluée **AVANT** le rôle : (a) contact portant une offre `accepted`, (b) contact retenu (`lead_contacts.status='selected'`, posé par « Retenir ce candidat »), (c) **unique** porteur d'offre quel que soit le statut de celle-ci ; repli sur la logique de rôle si rien ne matche. Le pool est restreint aux `preselectRole` fournis → une offre portée par le vendeur n'est jamais retenue. `getContacts` expose déjà `offers` + `status` par pivot, donc **0 appel supplémentaire**. `#gcompVendor` inchangé (`['seller','lessor']` + `preselectPrimary`). Les branches Blade `@if($pivots->isNotEmpty())` / `@else` (alerte + bouton qui fermait la modale) sont **supprimées** : le select + le bouton « + » (icc) sont TOUJOURS rendus, un hint `.ao-no-contact-hint`/`.aa-no-contact-hint` s'affiche et le submit reste `disabled` quand l'AJAX ne renvoie aucun candidat (état piloté par `aoSyncSubmitState`/`aaSyncSubmitState`, resynchronisé sur `change` → la création inline via « + » débloque le bouton). Le rendu serveur des options est conservé comme simple fallback. Les submits utilisent `$(this).closest('.modal')` au lieu de `$('#modalAddOffer')` (robuste si une copie périmée traîne). **0 route / 0 migration** (réutilise `GET /acquisition/lead/{id}/contacts`). E2E validé : staleness simulée (option retirée / select vidé) → liste fraîche + Elodie présélectionnée, empty state (0 contact → hint + submit disabled), offre réellement créée en base sur le bon pivot (puis supprimée), non-régression `loadLeadContacts` sans options (valeur = contact id + meta), 0 erreur console.
- **Grille tarifaire V2 — accès aux modules par plan (2026-07-10)** : plans **Basique 9,90/99 € (327/330) · Premium 39,90/399 € (328/331) · CRM 79,90/790 € (329/332)** (renommés depuis Standard/Premium/Team, multiusers=1 sur Premium+CRM), essai 286 = profil Premium. 13 nouvelles variables `core.project_extras` (ACCESS_LEAD/CRM/GED/WIDGET/PIPELINE/TEAM/PORTAL/SIGNATURE/AGENDA + SMS_CREDIT/YOUSIGN_CREDIT quotas + ACCESS_PDF_PARCEL/ACCESS_PDF_ADVERT_COMPARE) affectées via scripts SQL rejouables `.insights/sql-tarifs-v2-*.sql` (plans partenaires mappés basique/premium par nom — MAPPING À VALIDER ; extrait cadastral passé OFF sur Basique). **Middleware `planaccess:LABEL`** (`App\Http\Middleware\CheckPlanAccess`, alias dans AppServiceProvider, helper Blade `plan_access()` mémoïsé) posé par groupes dans web.php : acquisition light (kanban/statuts/contacts/labels/création) = ACCESS_LEAD, ~70 actions CRM (tâches/activités/proposals/générateurs/offres/EDL/required-docs) = +ACCESS_CRM, /documents = ACCESS_GED (sauf `check-tasks` + GET `defaults`), mutations /widgets = ACCESS_WIDGET, portail pro = ACCESS_PORTAL, /agenda/* = ACCESS_AGENDA (sauf POST fragment = teaser), pipeline+task-templates = ACCESS_PIPELINE, templates/quick-replies/honoraires/avis/registre = ACCESS_CRM, assignation+gestion agence = ACCESS_TEAM, PDF actions = ACCESS_ESTIMATE/PLAN_*/PDF_*. **Bridage fiche lead** : `HasAgency::hasLeadCapability` (core) intersecte le plan (`!hasPlanVariable('ACCESS_CRM')` → toutes capacités false, kanban/statuts/contacts conservés = « favoris avec kanban » Basique) ; lead_detail gate en plus hero NBA/cockpit/onglet comm (ACCESS_CRM) + onglet dossier (ACCESS_GED) + section Actions transverses. **SMS** : `SmsService::send` consomme `SMS_CREDIT` via `User::consumePlanQuota` (CRM = 100/mois, 1200/an) SAUF `context['bypass_quota']` (OTP portail/signataire, posé dans core-city-user) ; trace `error='quota_exhausted'` ; compteur+packs (partial `includes/_quota_recharges`) UNIQUEMENT sur `/compte/abonnement#recharges` (le doublon `/parametres#preferences` a été retiré le 2026-07-22, décision user). **Packs one-shot** : plans 334-336 Pack SMS 100/500/1000 (YEAR×50 ≈ sans expiration, prix placeholder À VALIDER), 337-338 Pack Yousign 5/20 (YEAR×1, **archived=1 tant que le driver Yousign est un stub**) — bascule plan→pack automatique via `getPreferedPayment` (vérifié tinker). **Signature électronique** gated : `resolveSignatureDelivery` → `assertSignaturePlanAllows` (435 si mode electronic sans ACCESS_SIGNATURE ; papier toujours OK). **Routes publiques gated par le plan du PROPRIÉTAIRE** : `/w/{slug}` + `/q/{uuid}` (redirect vitrine), config API widgets via `User::widgetConfig` (`is_active=false` → embed no-op), `/client/{token}` (410 dans ValidatePortalToken), `/sign/{token}` (410), `/avis/p/{slug}` + data (redirect/404) — helper core `User::hasPlanVariable` **FAIL-OPEN si la variable n'est pas déclarée pour le projet** (CaptImmo non affecté). **UI** : cadenas sidebar (`$paLock`), teasers génériques `includes/_plan_teaser.blade.php` + trait `BuildsPlanTeaser` (pattern MarketController) branchés sur widgets+agenda, onglets /parametres filtrés (taches=PIPELINE, templates/honoraires/avis=CRM, documents=GED). E2E validé sur les 3 comptes démo (214296 Basique / 214297 Premium / 214295 CRM, paiements prolongés +1 an ; user 2 basculé sur le plan CRM 329). Vitrine : `comparison_features` réécrit (colonnes Basique/CRM), FAQ + piliers renommés. **Passe 2 (2026-07-10)** : (a) plan CRM `quantity_min` 3→1 (329/332, dans le script SQL) ; (b) **UNIFORMISATION des anciens access** — 32 call-sites booléens `$this->isVariableAvailable('X', false)` convertis en `plan_access('X')` (11 contrôleurs : Advert/Opportunity/Market/Search/Filter/Profil/Parcel/Application/Action/TabEstim/Setting — les lectures NUMÉRIQUES `isVariableAvailable(x, false, false)` type FILTER_INDICATIVE_LIMIT/EXPORT_RESULT_LIMIT_* et les CONSOMMATIONS `useQuota` (EXPORT_RESULT, ACCESS_PARCEL TabInfo) restent inchangées) + middleware `planaccess:` ajouté sur les routes des anciens access : `/advert` data (list/search/count/full/compare/toggle = ACCESS_ADVERT ; show/card exemptés = rendent l'UX bloquée card_blocked), `/opportunity` search/count/list (index = promo page), `/estimate/*` + link-search (ACCESS_ESTIMATE), `/parcel/tab/annonces{,/matching}` (ACCESS_ADVERT — n'étaient pas gatés du tout). **390 routes gatées / 16 variables** au total. Quotas re-vérifiés : essai-only décrémente ACCESS_PARCEL (100→93), plan CRM = bypass illimité, OTP toujours gratuits — le middleware ne consomme JAMAIS (isVariableAvailable check-only). ⚠ un user avec essai + paiement réel : `getPreferedPayment` sert le paiement réel (l'essai est ignoré, voulu).
- **Signature Yousign eIDAS niv. 2 — choix de niveau sur compromis + baux (2026-07-10)** : sur les documents à fort enjeu (`config('signature.yousign_kinds')` = `Lead_proposal::KIND_COMPROMIS` (**= `'compromis'`, PAS `compromis_vente`**) + les 8 `leaseKinds()`), l'agent choisit dans `_signature_delivery` (param optionnel `$sigdelKind`, passé UNIQUEMENT par les modales compromis + bail) entre **Intégrée** (eIDAS 1 illimitée, défaut) et **Yousign** (eIDAS 2 avancée, **1 crédit `YOUSIGN_CREDIT`/signataire**, packs 337/338). Bloc `.sigdel-level-block` (radios `{ns}_signature_level`) visible en mode électronique seulement ; carte Yousign : badge « N crédits » / « 0 crédit »+CTA `#recharges` (disabled) / « Bientôt » (disabled, `YOUSIGN_ENABLED=false`). `SignatureDelivery.read()` expose `signature_level` (forcé `local` en papier) — payloads compromis (`phase2-actions.js`) + bail (blade `submitBail`) l'envoient. **Serveur** (`AcquisitionController`) : `yousignRequested()` + `assertYousignAllows($kind,$count)` (check PRÉCOCE avant PDF → 422 `yousign_kind_not_allowed|yousign_unavailable|yousign_credits_exhausted`+`cta_url`, interceptés par `ajaxError.yousign` → swal avec bouton Recharger) + `initiateYousignSignature()` (emails signataires requis ; POST séquence API v3 via `YouSignProvider` core-city-user ; `metadata.signature_provider='yousign'`+`provider_request_id` ; **crédits consommés APRÈS initiate réussi**). Les `Lead_proposal_signer` locaux restent créés (suivi UI) mais **`dispatchSignerInvitations`/`dispatchCompromisInvitations` sont SKIPPÉS** (Yousign envoie ses propres invitations email). **Webhook** `POST /webhooks/yousign` (`YousignWebhookController`, throttle 120/min, `webhooks/*` dans `VerifyCsrfToken::$except`, HMAC `X-Yousign-Signature-256`) : `done` → `downloadSigned` (PDF avec audit trail eIDAS 2 Yousign — **AUCUN re-render local**) remplace `path`+statut `signed_electronic` via `STATUS_BY_TYPE[$proposal->type_send]` + signers `signed_at` + User_document `share_with_client` + activité épinglée + fire `compromis_signed`/`bail_signed` ; `declined`/`expired` → statuts 8/9 + activité (crédits non restitués). Idempotent (`already_finalized`). **Activation prod** : `YOUSIGN_ENABLED=true` + `YOUSIGN_API_KEY` (forfait API PRO 129 €/mois) + `YOUSIGN_WEBHOOK_SECRET` (⚠ secret vide = HMAC non vérifié) + endpoint console Yousign ; sandbox `YOUSIGN_API_URL=https://api-sandbox.yousign.app/v3` ; puis `UPDATE core.project_extras SET badge=NULL WHERE label='YOUSIGN_LEVEL2'`. Harness 31+9 checks rejouables `.insights/yousign-2026-07-10/` (Http::fake, sans clé réelle) + E2E navigateur (toggle papier/électronique, read(), 0 erreur console). **Badges de quota CONTEXTUELS (2e passe 2026-07-10)** : `includes/_sms_quota_badge` est rendu `display:none` (classe `d-flex` RETIRÉE - son `!important` écrasait le masquage inline) et n'apparaît QUE quand le canal SMS est sélectionné, via la classe `.is-visible` (SCSS `_lead-actions.scss` : `display:flex !important`) - sigdel : `SignatureDelivery._sync` (SMS coché ET activé, caché en papier) ; sem/spm/sml : helper global `window.syncSmsQuotaBadge($scope)` + délégué `change` (phase2-actions.js) + appels explicites dans les openers (reset sem/spm, auto-pick spm ~l.827 SANS trigger change, prefill sml qui peut présélectionner SMS via data-prefer-channel) ; slp/sdp : délégué `click .lpf-channels .lpf-channel` + `setTimeout(0)` (laisser le handler métier poser `.active`) ; lead_matches (boutons bulk, pas de sélection persistante) : CSS `:has(.match-action-bulk[data-action=sms]:hover)` = badge au survol du bouton SMS. **Nouveau `includes/_yousign_quota_badge.blade.php`** (jumeau indigo : ok >5 / bas 1-5 / épuisé 0, « 1 crédit par signataire · packs valables 1 an », CTA `#recharges`) inclus dans le bloc niveau du sigdel (seulement si provider ready), visible UNIQUEMENT quand le niveau Yousign est sélectionné (`_sync`) - à 0 crédit la carte est disabled donc badge jamais montré (le hint statique « Plus aucun crédit Yousign » couvre ce cas). **PIÈGES badges** : (1) `d-flex` Bootstrap sur un élément masqué par style inline = toujours visible ; (2) DEUX attributs `style` sur un tag → le 2e est ignoré (fusionner) ; (3) tester le hover avec une vraie souris - coordonnées screenshot ≠ CSS (ratio viewport/capture). **PIÈGES** : (1) le handler `change.sigdelSign` doit scoper `.sigdel-sign-grid.first()` (sinon il retire l'active des cartes de NIVEAU) ; (2) échec initiate Yousign après création du proposal → 500, proposal pending sans invitation (supprimer + réessayer) ; (3) `billing_plan_extra_quotas` = lignes de conso matérialisées (payment_id polymorphe) — purger si on supprime un paiement de test.
- **Création de contact inline dans les modals fiche lead (composant générique `.icc-*`, 2026-06-30)** : bouton « + » à côté de chaque `<select>` de contact des modals de génération de doc → switch de vue → création d'un contact → l'option est injectée+sélectionnée dans le select → retour à l'étape (UX du pattern `agendaEventModal`/`btn-switch-to-agenda-contact`, mais **générique**, pas de copier-coller). **3 fichiers** : `acquisition/partials/_inline_contact_creator.blade.php` (params `$ns`/`$target`/`$title?` → bouton `.icc-add-btn` + vue `.icc-view` = `contact_form_content` préfixé `{ns}_contact_`) · `resources/js/inline-contact-creator.js` (bundlé `app.js`, handlers délégués `.icc-add-btn`/`.icc-back`/`.icc-submit` + toggle pro/civ) · `resources/sass/_inline-contact-creator.scss` (importé boot.scss). **Structure-agnostique** (modal PLATE ou MULTI-VUES) : au clic « + », la `.icc-view` est **promue en enfant direct du `.modal-content`** (`appendTo`, idempotent) + `.modal-content.icc-host{position:relative}` → s'affiche en **overlay** `position:absolute;inset:0;z-index:30;background-color:inherit` (theme-safe) qui couvre header/body/footer ; le retour révèle la `.modal-view` active inchangée (pas de hide/restore). Submit = `POST /contacts` (`ContactController@store`, réponse `{contact_id, contact_fullname}`) → `$(target).append(new Option(name,id,true,true)).trigger('change')`. **2 modes** : (a) **standard** (valeur du select = id CONTACT) → `POST /contacts` → `new Option(name, contact_id)` ; (b) **attach** (valeur = id PIVOT `Lead_contact`, params partial `$attachLeadId`/`$attachRole`/`$attachRoleLabel` → attrs `data-icc-attach-url`/`data-icc-role`) → `POST /contacts` PUIS `POST /acquisition/lead/{id}/contacts {contact_id,role}` (`attachContact`→`{pivot}`) → `new Option(fullname — roleLabel, pivot.id)`. **Câblé sur 15 selects / 12 modals** (select wrappé dans `.contact-input-wrapper`, `> select{flex:1;min-width:0}`) — **standard** : bon_visite `#gbvContactSelect`(gbv), compromis `#gcompVendor`(gcompV)+`#gcompBuyer`(gcompB), mandat `#gmnContactSelect`(gmn), mandat_notarial `#mntContact`(mnt — gaté capability notaire → absent du DOM pour un pro), preavis `#gpContactSelect`(gp), lettre_prospection `#slpContactSelect`(slp), demande_pieces `#sdpContactSelect`(sdp), groupe6_comm `#smlContactSelect`(sml), quittance `#qltLessor`(qltL)+`#qltTenant`(qltT), caution/`_modal_groupe5_docs` `#gcaTenant`(gcaT)+`#gcaLessor`(gcaL) ; **attach** : modalAddOffer `.ao-offer-contact`(ao, role `buyer`) + modalAddApplication `.aa-contact`(aa, role `tenant`) — includes dans `_lead_offers/applications_timeline` (fragment lead_detail ; ⚠ la branche conditionnelle sur des pivots non vides a été RETIRÉE le 2026-07-27, le select + le « + » sont toujours rendus, cf. puce AJAX-on-open ci-dessus). **NON touchés** : modalGenerateBail (déjà Select2+contactOptions), modalAddLead/AddLeadContact/sheetPdf/compare/parcelNote/agenda. `sem-recipient-select`/`invGuarantorType` hors périmètre. **Rafraîchissement fiche (mode attach)** : le rattachement modifie `$lead->leadContacts` → après succès, `Acquisition.refreshLeadContactSections(leadId)` rafraîchit **uniquement** `[data-section="contacts"]` + `[data-section="kpi-strip"]` via `GET /acquisition/lead/{id}/fragments?include=contacts_html,kpi_strip_html` (endpoint `leadFragments` étendu : helpers `renderLeadContactsHtml`/`renderLeadKpiStripHtml`, partials rendus standalone avec `['lead'=>$lead]`) → swap en place, **sans `showDetail`** (qui casserait la modale candidat promue en body + la sélection). Mode **standard = pas de refresh** (contact non rattaché → la section ne change pas ; rattachement + refresh à la génération du doc). **0 migration / 0 route** (réutilise `POST /contacts` + `POST /acquisition/lead/{id}/contacts` + `GET .../fragments`). **Pièges** : selects NATIFS (pas Select2) → `new Option`, pas d'avatar/preselect ; `injectOption` scopé au `.modal-content` du view (robuste cible CLASSE `.ao-offer-contact` + modale promue par `promoteFicheModal`) ; prefix par instance `{ns}_contact_*` ; lessor (qltL/gcaL) rendu seulement en mode non-particulier (`@else`) ; en attach `$offerPivots/$appPivots`=TOUS les lead_contacts (le rattaché réapparaît au reload) ; build `npm run dev` + hard-reload. E2E Chrome validé : standard (bon visite flat, mandat multi-vues vue préservée, compromis 2 selects, quittance, caution) + attach (offres lead 525 pivot buyer, demandes lead 624 pivot tenant — pivots vérifiés en base), 0 erreur console.
- **Widgets publics → leads CRM-ready (Lot A, 2026-06-27)** : les leads captés par les 3 widgets embarquables (Estimation/DPE/Opportunité) entrent enfin dans le modèle CRM post-refonte. Toute la logique vit côté **core-city** (trait `BuildsWidgetLead` + 3 controllers de capture — voir `core-city/CLAUDE.md`). Côté CE site : enrichissement JS des sélecteurs d'intention — champ unifié `project_type` (vendre/louer/acheter/investir/estimer → requirement_type) ajouté aux expériences widget qui n'en avaient AUCUNE (`resources/js/widget/src/{estimation/express|standard|complet, dpe/standard, opp/express|standard}/`), option « louer » ajoutée aux chips dpe/opp ; les bundles Rollup IIFE sont rebuildés via `cd resources/js/widget && npm run build` (servis depuis `resource_path('js/widget/*-widget.js')` par `WidgetScriptController`, **PAS** webpack/public). Un lead widget a désormais : `requirement_type` typé, `requirement_data` (critères matching), un `Lead_contact` (rôle + `consent_source=widget`), `estimation` du **bon type** (vente OU loyer), `kanban_order` (EN HAUT de colonne), event `lead_requirement_filled` → la fiche lead affiche le contact, la génération de doc n'est plus cassée, le matching a des critères. **Affinages 2026-06-27** : (a) estimations `immersive`/`premium` — les 2 champs d'intention contradictoires (`p_situation`+`p_project`) remplacés par le seul `project_type` ; (b) intention « louer » → estimation de **LOYER** (logique côté core-city, stats LOCATION) ; (c) **consentement RGPD** : badge affiché fiche contact (`contact/show.blade.php`, `ct-hero-badges`) + carte contact du lead (`_lead_contacts.blade.php`, `.lc-badge-consent` SCSS `acquisition.scss`) avec date+source ; `contact.js` envoie désormais `consent_contact` (1/0) dans `#formContact` (il l'omettait → la case du `modalContact` était inopérante au save) → case autoritative accord/retrait (logique `stampContactConsent` core-city-user). Harness + 4 E2E réels : `.insights/widget-lot-a-2026-06-27/` (harness, e2e_estimation [vente+loyer], e2e_dpe, e2e_opp, e2e_consent [5 scénarios]). **P1 + responsive (2026-06-27)** : (d) **label loyer** — réponse `estimate()` expose `is_rental` ; `result-renderers.js` (helper `rentalLabels`) → « Loyer mensuel estimé » + « €/mois » sur les 5 renderers en mode louer ; (e) **opp** chip/carte « Bailleur » + option « louer » (reason) ; (f) **alignement grilles** — `project_type`/`consent_contact`/`p_timeline` en `ip-w-field--full` (la question d'intention prend toute la largeur dans les steps `cols-2` immersive/premium, parité propre, zéro demi-champ orphelin). Rebuild Rollup. Vérifié navigateur (express = champs alignés, premium grille propre). **PIÈGES** : (1) 5 expériences widget (express/standard) ne collectaient aucune intention → le champ `project_type` JS est indispensable ; (2) `contact.js#formContact` construit un payload manuel champ-par-champ → tout nouveau champ à persister DOIT y être ajouté explicitement (sinon ignoré au save, comme l'était `consent_contact`) ; (3) `--full` (`ip-w-field--full`) se met dans le BUILDER du champ (project-fields.js), pas dans le step config ; un champ dans un step `gridLayout:cols-2` sans `--full` prend une demi-colonne (utile pour le pairing, mais une question principale ou une case longue doit être `--full`).
- **Solution B — Cascade parcelle/annonce → lead → estimation → modals (2026-06-12)** : pré-remplissage de bout en bout, zéro migration. **(0a) Source ANNONCE dans `leadPrefill`** (`AcquisitionController` ~l.11530) : lookup `Advert_location` (`user_id` courant, `multiparcel_id` **ltrim('0')** — aligné sur locateAdvert, `located_at` desc, limit 5) → helper `normalizeAdvertForPrefill($advLoc)` qui lit l'annonce **VIVANTE** via `Rootsud\Advert\Models\AdvertSearchIndex` (`flx_id`, connexion `pgsql_advert`, `orderByDesc(is_online, last_seen_at)`, try/catch) et **fallback `advert_snapshot`** (clés best-effort) seulement si disparue — jamais les archives. ⚠ DPE index au format `ENERGY_CLASSIFICATION_C` → regex `/^(?:ENERGY_CLASSIFICATION_)?([A-G])$/i`. Cascade : valeurs annonce injectées en **5ᵉ arg de `getEstimationParcelValue()`** (= PUD > annonce > BDNB), features annonce additives par clé (`$advFeat`), `hasGarden/hasTerrace/hasJacuzzi` annonce-only ; réponse enrichie d'un bloc `adverts[] {offer_type:'sale'|'rent', price, floor, flx_id, source:'live'|'snapshot'}`. **(0b)** `lead-actions.js::_applyParcelPrefillIfReady` applique prix→`#leadCriteriaPrice` + étage→`#leadFloorInput` UNIQUEMENT si type cohérent (seller↔sale, lessor↔rent), jamais sur saisie existante — vérifié E2E : vendeur 1 249 000 € + étage 2, bailleur même parcelle → loyer VIDE, physique conservé. **(0c) Code mort annonce-URL PURGÉ** de lead-actions.js (`resolveAdvertUrl`, handler `#leadAdvertUrl`, refs `#leadAdvertFlxId/Result/Error/Loading`, pill annonce du summary, `advert_flx_id` du payload submit — les éléments DOM n'existaient plus, blade l.36) ; le flux VIVANT `window.currentAdvertFlxId` (locate/pill contexte) intact. Le champ générique « **URL complémentaire** » (`input[name=url]`, `_lead_step2_criteria.blade.php`) est AUSSI supprimé (+ ses 3 refs JS prefill/payload + SCSS `.lead-url-input`) — une URL se note dans les notes du lead ; `leads.url` reste en lecture seule (historique préservé : `updateRequirement` ne touche `url` que si la clé est présente dans la requête — garde `$request->has('url')` déjà en place). **(1) Mapper `FeeController::buildLeadEstimationPrefill($lead)`** (static, seller/lessor only) : requirement_data → forme du bloc `criteria` (`{surface, bati_type, criteria:{nbr_chambres, year_construct, piscine…}}`) — clés vérifiées `{prefix}SurfHab/BatiType/Bedrooms/Parking/Balcon/NbAppartements/SurfSejour/ConstructYear/Standing/Emplacement/View/Exposition/Dpe/Ges/Features[]` ; BatiType chip string→enum (maison→1, appartement→7, immeuble→13, local→14, dependance→15, terrain→17), ConstructYear RANGE→borne inf ('< 1850'→1849), features→switches = inverse exact du bloc de leadPrefill, jamais de 0 explicite (absence ≠ non), `{prefix}Rooms` (pièces) volontairement NON mappé (l'estimation n'a que nbr_chambres=Bedrooms). + `FeeController::batiTypeToCategory(int)` public. **(2) Scénario A serveur** `TabEstimController::load` : `$leadForType` chargé avec `requirement_data` → `$leadPrefill` ; branche estimation = comble surface si null ; branche SANS estimation = surface lead prioritaire + batiType overridé **seulement si la catégorie diffère** (ne dégrade pas HOUSE_3→HOUSE) ; bloc criteria refactoré en `$estVars` + merge `$critToVar` (34 clés) pour les variables NON posées par l'estimation (priorité finale : estimation > lead > PUD > BDNB via le 4ᵉ arg de getEstimationParcelValue). Persistance auto au 1er recalcul (calculate → createParcelUserDetail) — `calculate` NON modifié. Vue `card_estimate_tab_criteria_modern.blade.php` : les 12 switches sans plomberie (`cuisine_equipee`…`interphone`) reçoivent désormais `$var ?? null` en 4ᵉ arg. **(3) Scénario B client** : `getLeadEstimationData` expose `lead_criteria` (mapper) dans LES DEUX branches (la branche sans estimation early-return-ait → c'était LE trou) ; `tab-estim.js` refactoré — applicateur unique `applyCriteriaFields(crit)` + `applyBatiTypePill()`, sans estimation → lead_criteria appliqué + `callback(true)` (l'appelant LinkPicker relance postEstimation), avec estimation → merge gap-fill (`crit[k] ?? lead_criteria.criteria[k]`) ; `userManuallyChangedPpm` JAMAIS posé pour le prefill lead ; ⚠ `#annee-construction` = SELECT à options RANGE → mapping année→range dans l'applicateur (bénéficie aussi au flux estimation). **(4) Aval** : garde `hasMatchingEstimation()` sur le LOYER de `bailPrefill` (~l.7070 — un bailleur avec estimation de VENTE 500 000 € la recevait comme loyer ; vérifié lead 595 → null ; la SURFACE reste non gardée = physique neutre, voulu) ; `preavisPrefill`/`compromisPrefill` déjà sûrs (check `source_type===0` explicite) ; `computeLocationFeeBreakdown` fallback mode manuel mono (items vides + total>0 → tout côté bailleur, éditable via pricing card). **(5) Style** : `_modal_edit_price`, `modal_add_match` (`#modalAddMatchManual`) et `_modal_estimation_detail` reçoivent `lead-modal-modern` EN PLUS des classes legacy (footer collé en bas vérifié computed pour les 3 ; sous-vues clonées mandat/bail/closeSale non impactées — le clone déplace body/footer, pas le modal-content ; à confirmer visuellement à l'usage). `modal_acquisition_filters`/`modal_photo_editor` volontairement NON alignées (pattern outil kanban/photos, pas des modals d'action fiche lead). Règle SCSS orpheline `#leadAdvertResult .btn-link` purgée de `_lead-actions.scss`. **E2E validé** (fetch session + DOM, onglet hidden → fragments) : prefill annonce live, contrôle négatif type, fragment serveur 662 (surface 50/chambres 3/année 1914-1947/works+mitoyenne/piscine non cochée/verrou intact), applyLeadEstimationData 45→50, leads vides/parcelles sans annonce inchangés, fiches 200, 0 erreur console, logs propres. **PIÈGES** : (1) `Advert_location.multiparcel_id` stocké SANS zéros de tête (ltrim locateAdvert) ; (2) DPE/GES index préfixés ENERGY_CLASSIFICATION_ ; (3) annonce multi-parcelles (multiparcel à 2+ cadastres) ne matche pas le lookup mono-cadastre de leadPrefill (assumé) ; (4) annonces disparues sans snapshot → skip silencieux ; (5) le mapper retourne null si requirement_data tout-null (leads anciens) — comportement actuel intact.
- **Solution C — Capacités fiche lead par mode pro (2026-06-11)** : bridage/débridage des actions de la fiche lead par tier de profil. **Matrice** `config/lead_capabilities.php` (tiers `pro`/`particulier`/`prestataire`/`notaire` × 17 capacités : `hoguet.mandates`, `hoguet.docs`, `mandate.registry`, `notarial.mandate`, `rental.docs`, `rental.edl`, `keys.pv`, `fees`, `adverts.publish`, `matching`, `offers`, `rental.applications`, `required.docs`, `docs.request`, `estimation.avis`, `close`, `portal` + liste `particulier_lease_kinds`). **Résolution tier à 3 étages** dans core `HasAgency` : `leadCapabilityTier()` = `isPrestataireServices()` (+`activityCategory()==='notaire'`→notaire) → `isHoguetCompliant()`→pro → particulier (JAMAIS `resolveProMode` seul : agent_commercial/salarie n'y figurent pas) + `hasLeadCapability($key)` (mémoïsé, fallback legacy si config absente — autres sites non impactés). **Helpers site** `lead_capability($key)`/`lead_capability_tier()` (`app/Helpers/lead_capability_helpers.php`, autoload composer). **Middleware** `capability:{key}` (`EnsureLeadCapability`) — ⚠ alias enregistré dans **AppServiceProvider::boot** (`$router->aliasMiddleware`), PAS dans app/Http/Kernel.php (bootstrap bind `Rootsud\Core\Http\Kernel` → Kernel local IGNORÉ, sinon `Target class [capability] does not exist`). **Gates UI** : sections accordéon `lead_detail.blade.php` ($cap* vars + `$showPiecesTab` qui masque l'onglet Pièces si aucune section permise — prestataire ; onglet Matching gaté `matching`), `_lead_hero_nba` (`$nbaCapByEvent` filtre `$nbaActionMap` + remap notaire `mandat_signed`→`ld-open-mandat-notarial`), `_lead_quick_actions` (gates par bouton `$show*`, plus de blanket isHoguetCompliant), `_lead_breadcrumb_actions` (`$bcCapByClass`), `_lead_other_legal_docs` (`$odCapByClass`), `_lead_mandats` (lien Registre gaté `mandate.registry`, variante notaire). **Honoraires particulier masqués partout** : `calculate_fees=null` si `!lead_capability('fees')` (AcquisitionController ×2), KPI strip card, kanban_card/lead_list_item chips, kanban.blade sommes colonnes (badge sans `data-lead-status-id` = toggle neutralisé), `dashboard/kpi.blade` cards + DashboardController. **NOTAIRE** (exempté Hoguet art. 2 loi 70-9) : kind `mandat_negociation_notariale` (constantes ×2 + KIND_LABELS + document_kinds), template système #201 audience='notaire', route `POST /acquisition/lead/{id}/mandat-notarial/generate` (`capability:notarial.mandate`), `generateMandatNotarial` (via generateSimpleProposal, AUCUN appel MandateRegistry) + réf interne `NEG-AAAA-NNNN` (`nextNotarialReference` : compteur annuel core.user_settings `notarial_mandate_seq_AAAA` sous lockForUpdate), modale `_modal_generate_mandat_notarial.blade.php` (body-level page_map_container, gatée capability ; ⚠ script inline body-level exécuté AVANT jQuery → poll natif `window.jQuery||window.$`), signature papier via markProposalSignedManual (kind ajouté à `$kindToTriggerEvent`→`mandat_signed` + `$isMandateKind` GED, PAS aux listes de scellement registre). **PACK PARTICULIER** (location entre particuliers loi 89-462) : colonne `modeles.audience` (NULL=pro, migration core-city-user 2026_06_11_000001) filtrée STRICTEMENT dans `findForKindScoped`/`availableForKind`/`resolveForKind` (ctx `audience`, PAS de fallback particulier→pro) ; 11 templates #202-212 audience='particulier' seedés par TRANSFORMATION déterministe des templates pro (migration 2026_06_11_000003 + harness rejouable `.insights/solutionC/transform_particulier_dryrun.php` : clause mandataire retirée, §7 honoraires→clause « aucuns honoraires d'entremise », letterhead carte T retiré, blocs Climat/encadrement CONSERVÉS, assertions zéro résidu) ; routes bail/caution/préavis→`capability:rental.docs`, PV→`capability:keys.pv` (les 13 autres routes gardent `hoguet`) ; **mode première personne** (`particulierFirstPartyIdentity()` : nom user requis 422, adresse billing_addresses sinon `……………`) dans generateBail (bailleur=user, lessor_contact_id nullable, kind borné aux 6 types — commercial/professionnel 422, **signature papier forcée**, bailleur ajouté comme signataire sinon `at_least_2_signers_required`), generateActeCautionnement (assert Hoguet bypassé — caution = art. 2297 c.civ pas un mandat), generatePreavis (expéditeur « Je soussigné »=user), generatePvRemiseCles (vendor=user) ; `generateSignableDoc` : assert Hoguet global SAUF kinds du pack particulier ; modale bail adaptée ($gblIsParticulier : 6 types, card Loyer simple sans pricing-card, bailleur « Vous », sigdel remplacé par hidden paper+download) ; avertissement notification LRAR (art. 15) ajouté à la modale préavis (tous tiers). **Templates tab** : `listTemplates` filtre par audience (variantes isolées par tier), `duplicateTemplate`+`storeTemplate` préservent/posent l'audience (sinon fork invisible + pollution résolution pro), kinds optgroups par capacité. **resolveTemplateAudience($kind)** (AcquisitionController) = point central audience (resolveDocumentTemplate + leadTemplates). EXCLUSIONS FERMES : compromis particulier (→notaire), bail commercial/professionnel particulier, registre Hoguet pour notaire/particulier. ⚠ Templates #201-212 : `params.legal_review_status=pending` → relecture avocat requise. **Invitations signataires** : `SendSignerInvitations` (core-city-user) early-return si `metadata.signature_mode==='paper'` (TOUS profils — l'email contenait un lien e-sign /sign/{token} contradictoire avec le papier ; relances incluses) + generateBail skip le dispatch pour le pack particulier. **Revue juridique levée (2026-06-11)** : templates #201-214 `legal_review_status=reviewed` (4 agents juristes, 47 corrections — footer `{{legal_notice_full}}` retiré des particuliers, IRL 17-1 supplétif, prev_rent hors zone tendue, `lease.works` câblé generateBail+`#gblWorks`, caution `#gcaRevision`+`lease_revision_conditions`+4 champs required (422), `asking_price` required_if conge_vendre, arrhes 1590 c.civ, art. 21 al. 3 exprès) ; sources pro corrigées en DB + **migration 000005** idempotente pour la prod ; **migration 000003 = seed littéral certifié** (72 Ko, plus de transform à l'exécution — égalité migrations↔base vérifiée octet-à-octet) ; harness `.insights/legal-review-2026-06-11/` (corrections.json + apply + render_check 25/25). **Baux PRO #146-152 aussi reviewed (2026-06-11 nuit, 19 corrections — 0 pending en table)** : commercial #149 (destination déterminée, ILC seul + indice de base, L.145-9 retiré du triennal, commissaire de justice), professionnel #150 (ILAT déterminé, DTA amiante), honoraires art. 5 complets (147/148 — l'art. 5 s'applique au bail mobilité via 25-12), **motif mobilité obligatoire** (`mobility_motif` select 7 motifs `data-lease-block=mobility_motif` + `required_if:bail_kind,bail_mobilite` + var `lease.mobility_motif` — mention 25-13-I-8° à peine de requalification), migration 000005 = 70 corrections fusionnées. **Quittance de loyer v1 (2026-06-11)** : kind `quittance_loyer` (capability rental.docs — pros + particuliers), templates #213 pro (« pour le compte du bailleur ») / #214 particulier (première personne) seedés par migration `2026_06_11_000004` (art. 21 loi 89-462 : gratuite, détail loyer/charges séparés, partiel = REÇU avec solde — blocs `{{#if quittance_is_full/is_partial}}` À PLAT, clause paiement pré-assemblée serveur `quittance_payment_label`), `generateQuittance` + route `POST .../quittance/generate` (gardes 422 : partiel≥total, email sans accord exprès du locataire al. 4), envoi `SendModelByMail`, modale `_modal_generate_quittance` (body-level, style caution/mandat, bouton « Quittance » header section Baux, quittances listées dans la section — statut 2 hors $activeStatuses). E2E validé 4 tiers (bail particulier 346 signé papier statut 7 sans entrée registre + re-test post-audit #351 sans invitations, NEG-2026-0001 #350, presta sections immo masquées, pro non-régressé). Livraison + audit : `.insights/solutionC-livraison-2026-06-11.md`.
- **⚠ PIÈGE flx périmé : `launchQuickParcelsSearch` resync `currentAdvertFlxId` (2026-06-26)** : lors d'une navigation **annonce → annonce** (SPA), `window.currentAdvertFlxId` pouvait rester PÉRIMÉ (annonce précédente) quand `launchQuickParcelsSearch` tournait → le `located-bounds` (fitBounds), les états « Liée » (`location-states`) ET la liaison au clic ciblaient la **mauvaise annonce** (symptôme : fitBounds sur la parcelle liée d'une AUTRE annonce, « 1 parcelle liée » fantôme). **Rappel** : `/annonce/{id}` → `{id}` = `Propertie_advert.id` INTERNE, PAS le `flxId` (ex. annonce 11578909 → flxId 1011948447) ; le flx autoritatif = `$('#tab-advert .btn-favorite').attr('data-advert')` (posé à l.682 au chargement de la carte). **Fix** : en TÊTE de `launchQuickParcelsSearch`, resync `window.currentAdvertFlxId = $('#tab-advert .btn-favorite').attr('data-advert')` (si présent) AVANT toute utilisation (boutons + located-bounds) → tout cible l'annonce réellement affichée. Vérifié : located-bounds(1011948447)=count:0 (annonce sans parcelle liée → top-10 normal), resync 768507357→1011948447 OK.
- **fitBounds carte sur les parcelles LIÉES à l'ouverture de la fiche annonce (2026-06-26)** : à l'ouverture d'une annonce, le `colMap` (Google Maps) cadrait toujours sur le top-10 (`launchQuickParcelsSearch` → `$.post(searchUrl) → layerResult(resp, true)`). Désormais : si l'annonce a **1+ parcelles liées** (`Advert_location`), le fitBounds cible **ces parcelles** ; sinon le top-10 (inchangé). **Backend** : `GET /acquisition/advert/located-bounds/{flxId}` (`AcquisitionController::advertLocatedBounds`) → `{count, bounds:{w,s,e,n}}` — décode les `multiparcel_id` des `Advert_location` (user+flx) en cadastres puis `ST_Extent(geo_shape::geometry)` sur `core_city.fr_parcels WHERE cadastre_id IN (…)` (connexion `pgsql_city`) ; `count=0` si aucune. **JS** (advert_card.js, bloc map post) : `doMapSearch(located)` — GET located-bounds d'abord (toujours résolu, fallback null sur fail), puis `layerResult(mapResponse, !located)` (fitBounds top-10 SEULEMENT si pas de parcelle liée) + si `located.bounds` → `window.fitBounds(map, corners, deferred)` (4 coins LatLng du bbox, helper core-city maps.js ; `deferred=!isShowMap()` → `needFitBounds`). E2E vérifié : endpoint (advert 768507357 → count:5 + bounds Villeneuve-de-la-Raho / advert inconnu → count:0), vrai `fitBounds` recentre la carte sur le bbox (`moved_to_target:true`), 0 erreur console.
- **Refonte UI « Lier la parcelle » : contrôle `.parcel-locate` 2 styles + libellé statut (2026-06-26, 4ᵉ passe — supersede le visuel `.btn-locate-parcel` des passes 2/3)** : remplace l'icône-pastille par un contrôle clair piloté par état. Chaque `.resultListItem` (en contexte annonce) reçoit un **contrôle `.parcel-locate`** (le libellé `.parcel-link-label` NON LIÉE/LIÉE a été RETIRÉ 2026-06-26 — superflu + trop de hauteur perdue ; l'état est déjà lisible sur le bouton) (`data-code`/`data-flx-id`/`data-located`/`data-locate-style`/`data-location-id`) à 2 styles : **`row`** (top-10) = ligne **pleine largeur en bas de carte** — non liée : bouton pointillé vert `.pl-row-link` « 🔗 Lier cette parcelle à l'annonce » ; liée : bandeau vert plein `.pl-row-linked` « ✓ Parcelle liée à l'annonce » + lien `.pl-modify` « Modifier » (= délier) ; **`compact`** (recherche) = bouton icône+label dans `.parcel-indicators` (1er à gauche) — `.pl-compact-link` vert clair « Lier » / `.pl-compact-linked` vert « Liée ». **JS (advert_card.js)** : `addLocateControl($item, style)` (injecte libellé+contrôle, idempotent), `renderLocate($item)` (rend libellé+HTML interne selon état), `setLocateState($item, located, locId, leadId)`, `injectLocateTop10($results)` (style row, appelé après rendu top-10), `injectLocateButtonsInResultList()` (style compact, gate `currentAdvertFlxId`, appelé par eventResultList), `refreshLocateStates($scope, flx)` (POST location-states → setLocateState). **Clic** `bindLocateButtonClicks($scope)` (lead-actions.js) = **délégation sur `.parcel-locate`** (le conteneur PERSISTE alors que `renderLocate` fait `$ctrl.html()` → un binding direct sur `.parcel-locate-action` serait PERDU au 1er `refreshLocateStates`/toggle ; ⚠ bug corrigé) ; `.parcel-locate` étant sous `.resultListShowParcel`, `stopPropagation` empêche l'ouverture de la fiche parcelle. **SCSS** `result.scss` : `.parcel-link-label`, `.parcel-card.has-locate-row{height:auto;flex-wrap:wrap}` (la ligne wrappe sous preview+contenu via `flex:0 0 100%`), `.pl-row*`/`.pl-compact*`. **Wording harmonisé** « lié/délier » (toasts + swal « Délier cette parcelle ? »). E2E vérifié (capture conforme aux 2 maquettes, labels, full-width row, compact 1er dans indicateurs, 0 erreur console). ⚠ `.parcel-card` partagé : `has-locate-row` (modificateur) n'impacte que les cartes en contexte annonce.
- **Bouton localiser aussi dans les résultats de recherche `#resultList` (2026-06-26, 3ᵉ passe)** : quand la parcelle n'est pas dans le top-10, l'utilisateur lance une recherche (`filterBtnSearch`) → résultats dans `#resultList` ; le `.btn-locate-parcel` y est désormais injecté **UNIQUEMENT en contexte fiche annonce** (`window.currentAdvertFlxId` non nul — cleared en quittant l'annonce, lignes 197/715/1184 d'advert_naviguation.js). Helpers factorisés (advert_card.js) : `window.addLocateButtonToCard($card, cadastre, flx, rank)` (crée+place le bouton 1er dans `.parcel-indicators`, idempotent) — utilisé par le top-10 ET `window.injectLocateButtonsInResultList()` (gate `currentAdvertFlxId`, boucle `#resultList .resultListItem`, bind + refresh états). Hook : appelé en fin de `eventResultList()` (search.js:~1434) → couvre recherche initiale + pagination (le hook est commun aux 3 rendus 468/670/688). **Clic unifié** : `window.bindLocateButtonClicks($scope)` (lead-actions.js) remplace le binding inline du top-10 — binding DIRECT (pas délégué) car le bouton est dans `.parcel-content.resultListShowParcel` → `stopPropagation` empêche l'ouverture de la fiche parcelle (un handler délégué sur `document` se déclencherait APRÈS le handler `.resultListShowParcel` de `.app-component`). E2E vérifié (hors annonce=0 bouton, en annonce=1/résultat 1er dans indicateurs, idempotent, parcelle déjà localisée→vert✓, 0 erreur console). NB : `refreshLocateButtonStates` re-poste tous les cadastres de `#resultList` à chaque rendu (acceptable, liste paginée).
- **Bouton localiser : état visuel + toggle d'annulation + positionnement (2026-06-26, 2ᵉ passe)** : le `.btn-locate-parcel` du top-10 a maintenant **2 états** pilotés par `window.setLocateButtonState(btn, located, locationId, leadId)` (lead-actions.js) — **non localisé** = neutre (fond blanc, bordure grise, icône `fa-map-pin`) / **localisé** = vert `#28a745` + icône `fa-check` (classe `.is-located`), placé **dans la rangée `.parcel-indicators`** (1er à gauche via `$indicators.prepend($btn)`, le **score à droite**), en **flux normal** (`.btn-locate-parcel` 32×32, plus d'absolu/`has-locate-btn` ; fallback : crée `.parcel-indicators` dans `.parcel-header` si absente). **État au rendu** : `window.refreshLocateButtonStates($scope, flxId)` (advert_card.js) fait UN POST `/acquisition/advert/location-states` (`AcquisitionController::advertLocationStates` : `{advert_flx_id, cadastre_ids[]}` → `{states:{cadastreId:{located,location_id,lead_id}}}`, encode cadastre→multiparcel ltrim aligné sur locateAdvert) et bascule chaque bouton. **Toggle au clic** (handler advert_card.js branché sur `data-located`) : non localisé → `confirmParcelLocation(flx,cad,{onLocated})` (localise + propose lead, `onLocated(locationId,leadId)` repeint le bouton) ; localisé → `window.unlocateAdvertConfirm(locationId, cadastreId, {onUnlocated})` = swal « Annuler la localisation ? » → `POST /acquisition/unlocateAdvert/{id}` (method spoof `_method=DELETE`) → repeint non localisé. **Le lead créé n'est JAMAIS supprimé** à l'annulation (volontaire). **Idempotence backend** : `locateAdvert` passé en `firstOrNew([user,advert_flx_id,multiparcel_id])->fill()->save()` (plus de doublon Advert_location) ; `unlocateAdvert` supprime la ligne ET ses doublons (même annonce+parcelle) pour que l'état « non localisé » soit fiable. `locateAdvertThenAskLead` renvoie `response.location_id` au callback. Routes ajoutées : `POST /acquisition/advert/location-states` (avant catch-all). E2E vérifié (endpoint live, bascule des 2 états + vert/check, 0 erreur console). Le flux clic droit carte (`locateAdvertOnParcel`) inchangé (pas d'état persistant à refléter).
- **Localisation annonce → parcelle : bouton intégré + flux unifié (2026-06-26)** : (1) **Bouton « C'est cette parcelle »** du top 10 (`advert_card.js` ~l.1314) intégré DANS la `.parcel-card` en **icône absolue** (`<i class="fas fa-map-pin">` dans `.btn-locate-parcel > .btn-locate-parcel-circle`), injecté comme **enfant direct** de `.parcel-card` (`$item.find('.parcel-card').addClass('has-locate-btn').append(...)`), plus dans `.quick-parcel-actions` appended à `.resultListItem` (qui débordait). SCSS `result.scss` calqué sur `.opp-add-lead-btn` : `.resultListItem .parcel-card > .btn-locate-parcel { position:absolute; top:4px; right:9px; 22×22; vert succès }` + `.parcel-card.has-locate-btn > .parcel-content { padding-right:36px }` (modificateur scoppé → **zéro impact sur les `.parcel-card` partagées sans bouton** : la carte sans bouton reste à padding 14px et ne bouge pas — vérifié synthétiquement). (2) **Comportement unifié** : helper `window.locateAdvertThenAskLead(flxId, cadastreId, {benchmarkData})` (`lead-actions.js`) **localise TOUJOURS au clic** (POST `/acquisition/locateAdvert` SANS `create_lead`) → toast + `markCreated('advert')` + `loadParcelLeads`, PUIS swal **Oui/Non « Ajouter aux leads ? »** (`simpleSwalWithoutAjax`, callback en 1er) → Oui = `createLeadFromLocatedParcel` = création rapide 1 clic `POST /acquisition/lead/create {source:'advert', cadastre_id, adresse_complete}` (calqué sur `.opp-add-lead-btn` : toast + `markCreated('leads')` + incrément `#sidebarLeadCount`). `confirmParcelLocation` (bouton top-10, passe `buildBenchmarkData`) et `locateAdvertOnParcel` (clic droit carte `#btnLocateAdvertHere`, flx = `currentAdvertFlxId`, garde « Aucune annonce ouverte ») **délèguent** au helper. **Supprimés** : `showLocatePopup`/`executeLocateAdvert` + le fetch `parcelLeads` + l'option « lier à un lead existant » (reste de l'ancien système : `Advert_location` est clé sur `multiparcel_id` → tous les leads de la parcelle profitent de l'annonce via le prefill Solution B, le `lead_id` ne servait qu'à une activité + trigger `advert_linked`). Bundlé dans `app.js`/`app.css` → `npm run dev` + hard-reload (cache immutable). 3 fichiers, 0 migration/route/modèle.
- Application SaaS cadastre/immobilier, core en mode passif (routes locales, mix possible avec pages admin)
- 7 cores Rootsud integres, 8 connexions PostgreSQL vers schemas differents
- Build frontend : Laravel Mix 6, un seul bundle `public/js/app.js` (32 fichiers JS) + `dashboard-kpi.js` en bundle separe
- 3 widgets embarquables (Estimation, DPE, Opportunite) : Shadow DOM, Vanilla JS, Rollup IIFE — builds separes hors webpack
- **Portail client — lecture du document AVANT signature (modal 2 étapes, 2026-06-04)** : le flux de signature MONO-PARTIE in-portail (`client_portal/partials/_documents.blade.php`, modal `#cpSignModal`, statut `Lead_proposal=pending_sign(5)`) n'offrait AUCUN aperçu du document (≠ flux page pleine multi-parties `sign_signer.blade.php` qui a son bouton « Consulter »). La modal `#cpSignModal` est désormais en **2 étapes** : `[data-cp-step="read"]` (aperçu PDF dans une `<iframe id="cpSignPdf" class="cp-sign-pdf">` + lien fallback `#cpSignFullscreen` « Ouvrir en plein écran » pour iOS qui ne rend pas toujours les PDF inline + bouton `#cpSignGotoSign`) → `[data-cp-step="sign"]` (canvas/taper + nom + termes + `#cpSignSubmit`, bouton retour `#cpSignBackRead`). Avantages : aperçu intégré (desktop), zéro conflit scroll/tracé sur mobile (iframe et canvas jamais affichés simultanément), gate de lecture (le bouton Signer n'apparaît qu'après l'étape Lire = consentement éclairé eIDAS). **Backend** : nouvelle méthode `ClientPortalPublicController::downloadPortalProposalPdf(Request,$token,$proposalId)` (réplique `downloadSignerProposalPdf` mais résout via `portal_token` injecté par middleware `portal.token` + scope `where('lead_id',$portalToken->lead_id)` = sécurité identique à `signProposal`), sert le PDF **inline** (`Storage::disk('private')->response(...)` avec `Content-Disposition: inline`, ≠ `->download()` du flux page pleine) ou preview live `HasModel::pdf(...,$storage=false)` si pas de `path`. Route `GET /client/{token}/proposal/{proposalId}/download` (name `client.portal.proposalDownload`, regex `[0-9]+`, dans le groupe `['throttle:60,1','portal.token']` à côté de `signProposal`). URL exposée via `CP_CONFIG.urls.proposalDownloadBase` (`layout.blade.php`). **JS** (IIFE inline `_documents.blade.php`) : `openModal()` charge `iframe.src`/`fullscreenLink.href` + `showStep('read')` SANS init le pad ; handler `#cpSignGotoSign` → `showStep('sign')` PUIS `setTimeout(initPad,100)` (le canvas doit être VISIBLE pour être dimensionné — sinon width 0) ; `closeModal()` reset à `read` + vide `iframe.src='about:blank'`. **SCSS** `client_portal.scss` : `.cp-sign-step{display:none}.is-active{display:block}`, `.cp-sign-steps`/`.cp-sign-step-dot`, `.cp-sign-pdf{height:min(62vh,520px)}` (56vh mobile), `:has(.cp-sign-step[data-cp-step="read"].is-active){max-width:760px}` (élargit la modal en lecture, 100% mobile). **PIÈGES** : (1) le bouton retour `#cpSignBackRead` porte aussi `.cp-sign-cancel` mais `modal.querySelector('.cp-sign-cancel')` cible le PREMIER (= « Annuler » de l'étape read) → seul lui reçoit `closeModal`, le retour a son handler par id ; (2) le flux multi-parties (`pending_sign_multi=11`, page pleine `/sign/{token}`) n'est PAS touché ; (3) `buildDocumentsSection` (core-city-user) ne liste QUE `pending_sign(5)` → les docs multi-parties n'apparaissent jamais dans le portail. Vérifié Chrome (desktop+mobile 390px) : aperçu PDF OK, canvas width=472, signature bout-en-bout (bon_visite 233 → status 6), 404 cross-lead, 0 erreur console.
- Sidebar restructure en mode "smart" (`sidebar-smart`) avec 3 sections labellisees (Principal, Acquisition, Outils) + badge compteur leads
- Content bar (`includes/content_bar.blade.php`) : barre de recherche autonome (IDs sans prefix : `#map-search-input`, `#map-search-btn-sector-history`, etc.) + scope pill interactive (`#cbScopePill` + popover `#cbScopePopover` 8 options) + toggle carte dans `#colApp`, en dehors de `#colMenu` pour survivre aux remplacements AJAX. Remplace le search original de core-city sur Google Maps (cache via CSS `#map-center-container { display:none!important }` + JS strip IDs dans map.js). JS dans `content-bar.js` + `omnibar.js`, styles dans `_content-bar.scss`. Raccourci `/` pour focus search, auto-detection URL collee → mode Annonce, hint kbd
- Omnibar unifie (`omnibar.js`) : recherche universelle GEO + CRM + Secteurs avec dropdown uniforme. Scope pill 8 types (Auto/Lieu/Ref.cad/Contact/Lead/RDV/Estimation/Annonce). GEO via `POST /city/search/json` (core-city JSON endpoint), CRM+Secteurs via memes scopes. Rendu client-side uniforme `.ob-*` pour 12 types (7 geo + 4 CRM + sector). Mode Auto contextuel (priorities par menu + `_mapVisible`). Champs cadastraux 3 niveaux (JSON). Intercepte `refreshSearchDropdown()` pour tout router via le pipeline JSON unifie. Empty state avec CRM pills contextuelles (4 pills mode Auto, geo mode Lieu, single pill modes CRM filtres). `setType()` sync scope pill via `window.updateScopePill`
- **Omnibar `reset()` + reset à l'entrée Analyse de marché (2026-06-04)** : nouvelle méthode publique `window.Omnibar.reset()` (`omnibar.js`) qui remet la barre à zéro — vide `#map-search-input` (+ ses `data-layer`/`data-code`/`data-extra`/`data-advert`/`data-filters`), repasse le scope à `auto` via `this.setType('auto')` (resync la scope pill `#cbScopePill` sur « Auto » + vide le dropdown + reset le sous-module cadastral), puis resync l'icône bouton via `window.updateSearchBtnState()`. Appelée dans `menu-map.js` case `#menuOnMapContainer-btn-left-menu-market` (bloc `reloadContent`, après les `clean*` et avant `loadPostContentMenu`) → en arrivant sur `/analyse-marche` une ville/lead/contact sélectionné dans un menu précédent ne persiste plus. Couvre aussi le deep-link bootstrap (`map.js:1374`). Réutilisable pour réinitialiser l'omnibar depuis n'importe quel point d'entrée. **Nettoyage carte associé** (même case, juste après le `reset()`) : `clearSelectionDrawing()` (efface un dessin libre terraDraw, comme les autres menus qui l'appellent à l'entrée) + `getMap(m => cleanLayer(m, 'sector'))` qui retire un **secteur** précédemment sélectionné/dessiné (layer `'sector'`, `city.layers['sector']`/`city.current['sector']`) — `cleanlayerResult()` ne touche PAS ce layer. `cleanLayer(map,'sector')` est la fonction canonique core-city (maps.js) déjà utilisée partout pour retirer un secteur. Vérifié Chrome (clic menu + deep-link, scope `auto`, `clearSelectionDrawing` + `cleanLayer('sector')` appelés 1×, `city.current['sector']` vidé, 0 erreur)
- **Card Building « Valeur par défaut » (2026-06-04)** : dans `parcel/card_building.blade.php` (`.bd-default-info` des dropdowns `.bd-field`), le libellé de la valeur pré-remplie ne cite plus la source — « Donnée BDNB : » (single) et « BDNB : » (multi) unifiés en **« Valeur par défaut : »**. Modif Blade-only (texte statique, plus de branche `@if($mode==='multi')` sur ce libellé). Le commentaire PHP `// Default BDNB` est conservé (code uniquement)
- **Écran premium « Analyse de marché » non stylé → aligné sur Opportunité (2026-06-04)** : la branche `@else` de `market/market.blade.php` (affichée quand `!$accessMarket`, feature `ACCESS_MARKET`) réutilisait des **classes obsolètes** `.promo-features` / `.promo-feature` + `<h4>`/`<p>` nus qui **n'ont AUCUNE règle SCSS** (d'où l'affichage cassé). Tous les styles `.promo-*` vivent dans `resources/sass/common.scss` (classes **globales**, non scopées) et sont écrits pour les noms utilisés par `opportunity/opportunity.blade.php` : `.promo-features-section` + `.promo-section-header` + `.promo-features-grid` (grid 2 cols) + `.promo-feature-card` + `.promo-feature-icon promo-feature-icon-{home|lock|sync|map}` (variantes couleur) + `.promo-feature-content > .promo-feature-title/.promo-feature-desc`. Fix = réécriture de la section features du market avec ces classes (4 cards en grille) + ajout du `.promo-premium-badge` (couronne) manquant dans le `.promo-premium-banner`. Le hero (`.promo-hero*`), le banner (`.promo-premium-*`) et le CTA (`.promo-cta-*`) étaient déjà OK (classes communes). **Blade-only, pas de rebuild** (les `.promo-*` sont déjà dans `app.css`). **Règle** : pour tout écran premium/upgrade d'un menu InfoParcelle, réutiliser le set de classes `promo-*` d'`opportunity.blade.php` (= celles réellement stylées dans `common.scss`), ne pas inventer de variantes. Vérifié Chrome (computed-styles : `.promo-features-grid`=grid 2 cols, `.promo-feature-card`=padding/bordures, icône=gradient ; ancien markup=block/0 ; + capture rendu complet `.insights/verif-market-premium.png`)
- **SPA cassé sur les menus du dropdown « Outils » (Historique / Registre des mandats) → fix onclick (2026-06-04)** : cliquer `#menuOnMapContainer-btn-left-menu-history` ou `-mandats` rechargeait toute la page au lieu de naviguer en SPA. **Cause** : ces 2 items sont des `<a href>` (`.sidebar-user-panel-item`) **imbriqués** dans le `<li class="sidebar-bottom-dropdown">` « Outils » de `template.blade.php`. Le handler délégué `$('.app-sidebar ul').on('click','li')` (map.js) **retourne tôt** (sans `preventDefault`) quand le `li` le plus proche a la classe `sidebar-bottom-dropdown` (correct : le dropdown ne doit pas déclencher `showLeftMenu`) → le `<a href>` navigue normalement = reload. Les `case` `showLeftMenu` (`-history` l.644, `-mandats` l.880 dans menu-map.js) sont pourtant de vrais loaders SPA (`loadPostContentMenu`+`pushState`) ; il manquait juste l'interception du clic. **Fix** = ajout d'un `onclick` inline sur les 2 `<a>`, **copie exacte du pattern de l'item « Paramètres »** du user-panel : `if(!event.ctrlKey && !event.metaKey && !event.shiftKey && event.button===0 && typeof showLeftMenu==='function' && !/^\/compte(\/|$)/.test(location.pathname)){ showLeftMenu('#...'); return false; }`. Garde le `href` en fallback (ctrl/cmd-click = nouvel onglet ; pages `/compte/*` où le SPA map n'est pas chargé = vraie navigation). **Blade-only, pas de build**. **Règle** : un menu placé dans un sous-panneau `.sidebar-bottom-dropdown`/`.sidebar-user-panel` n'est PAS capté par le handler `li` global (exclu volontairement) → lui ajouter l'`onclick` SPA (pattern Paramètres). Vérifié Chrome (marqueur `window` survit = pas de reload, URL→`/historique` & `/registre-mandats`, `_currentMenu` ok, 0 erreur)
- **Empty state `/historique` modernisé (2026-06-04)** : la branche `@else` de `history/history_list.blade.php` (override site, affichée quand `$histories->isEmpty()`) utilisait le vieux pattern `text-center mt-5` + `<p class="text-muted">` + grosse flèche `fa-5x` + bouton `#choose-place` « Je sélectionne mon lieu de recherche ». Remplacée par le composant standard **`.crm-empty.crm-empty-page`** (`_empty-states.scss`, déjà importé dans boot.scss → pas de build) : icône ronde `.crm-empty-icon` (`fa-history`) + `.crm-empty-title` « Aucun historique pour le moment » + `.crm-empty-description` (explique que les recherches de parcelles/annonces s'y enregistrent automatiquement). **Bouton supprimé** (pas pertinent dans l'historique). Le handler délégué `filter.js` `$('.app-component').on('click','#choose-place')` devient orphelin mais inoffensif (`#choose-place` n'existait QUE dans cet empty state ; les autres CTA « choisir un lieu » sont `#counterChoosePlace`/`.counter-choose-place`, ids distincts). **Règle empty state** déjà notée : utiliser `.crm-empty` (`+ -compact` panneaux réduits / `+ -page` pleine page) au lieu de `text-center text-muted py-3`. Vérifié Chrome (computed `.crm-empty-page`=flex centré min-height 60vh, icône 72px, 0 bouton ; capture `.insights/verif-history-empty.png`)
- **Feedback (« Signaler un bug » / « Proposer une idée ») dans le dropdown d'aide du dashboard (2026-06-04)** : ajout des 2 liens feedback (déjà présents dans la sidebar `#sidebarHelpPanel`) au dropdown `.welcome-dropdown-help` du header dashboard (`dashboard/_welcome.blade.php`, après l'item « Contact »). **Adaptés au markup du dropdown** (≠ markup sidebar) : `<a class="welcome-dropdown-item" href="{{ URL::to('/dashboard') }}#openFeedback{Bug|Improvement}" data-feedback-type="bug|improvement" onclick="if(getElementById('modalFeedback') && showModal){ $('#modalFeedback').data('feedback-type', '…'); showModal('#modalFeedback'); return false; }">` + `<span class="welcome-dropdown-icon bg-{danger|warning}-light text-{danger|warning}"><i class="fas fa-{bug|lightbulb}"></i></span>` + `<span class="welcome-dropdown-text"><span class="welcome-dropdown-title">…</span><small>…</small></span>`. Les variantes `bg-danger-light`/`bg-warning-light` existent déjà dans `dashboard.scss` (mêmes blocs que `bg-primary/info/success-light` utilisées par les items existants) → **Blade-only, pas de build**. Le `feedback-type` est lu PUIS `removeData` par `feedback.js:46-48` au `show` de la modale (d'où un `$modal.data('feedback-type')` à `null` après coup = normal, consommé). `href` en fallback (env-agnostique via `URL::to`, ≠ URL ngrok en dur). Vérifié Chrome (5 items dans le dropdown, clic « bug » ouvre `#modalFeedback`, le 404 console est préexistant/analytics — présent au reload propre, sans rapport)
- **Retrait de « Guide decouverte » du dropdown d'aide du dashboard (2026-09-02)** : l'item `#resetTourGuide` (`dashboard/_welcome.blade.php`, entre « Tutoriels » et « Contact ») est **supprime** - le dropdown `.welcome-dropdown-help` passe de 5 a 4 items. ⚠ **Le tour interactif n'existe plus en donnees** : `core.project_site_guided_chapters` compte **0 ligne, tous sites confondus** (mesure avant retrait), donc le bouton etait deja mort. Son clic declenchait pourtant **2 requetes serveur** (`POST /parametre/reset-guided` puis `POST /parametre/guided` via `resetGuidedTour()` → `initGuidedTour()`, `core/resources/js/guided_tours.js`) pour **zero effet visible** : `initGuidedTour` sort silencieusement quand `GUIDED_TOURS[chapitre].items` est vide. ⚠ **Aucune modification du core** : son handler est deja garde par `if($('#resetTourGuide').length > 0)`, il devient donc inerte des que l'element disparait - le toucher imposerait un tag + un rebuild du bundle `app-core.js` pour zero gain. L'infrastructure `guided_tours` (intro.js, les 15 helpers `window.guided_tours_*` du site, `POST /parametre/guided`) est **conservee telle quelle** : hors perimetre, elle se reactiverait en creant des chapitres depuis /administration. ⚠ Le panneau Aide de la **sidebar** (`#sidebarHelpPanel`, `template.blade.php`) ne portait deja plus cet item ; son commentaire « Handlers specifiques (DocHub, Guide decouverte) » etait perime et a ete corrige. **0 migration, 0 route, 0 JS, 0 SCSS, 0 fichier de package → aucun build, aucun tag, aucun `composer update` : `php artisan view:clear` suffit.** Verifie par **A/B en restaurant le fichier d'origine** (code d'origine → 5 items dont « Guide decouverte » ; corrige → **4 items**, `resetTourGuide` introuvable dans le DOM comme dans le fragment `POST /dashboard`), dropdown reellement ouvert au clic dans le navigateur (hauteur 229 px, 4 items tous visibles, aucune ligne vide), **0 erreur console** (seul l'avertissement Cookiebot prealable, lie au domaine ngrok) et **0 ligne de log**.
- Systeme unifie de toggle carte : `toggleMapVisibility(show)` dans `responsive.js` remplace les anciens `fullScreenMenu()`/`halfScreenMenu()`. Preferences carte per-menu via localStorage (`map_visible_<menuKey>`). Config des menus dans `_menuDefaults` (menu-map.js) avec `defaultMap` et `search` par menu
- Bottom tab bar mobile (5 items + bottom sheet) via `includes/mobile_nav.blade.php` (icones duales Dashboard, sheet etendu KPI+Parametres), styles dans `mobile-nav.scss`
- **Sidebar mobile (2026-06-25)** : (1) **densité** — bloc `@media (max-width:767.98px)` dans `mobile-nav.scss` (items `.vertical-nav-menu li a` 2.3rem + font 0.8rem, `.app-sidebar__heading` 0.6rem, séparateurs/badges resserrés) pour que la sidebar tienne SANS scroll vertical sur téléphone (desktop inchangé). (2) **fermeture** — quand la sidebar est ouverte (`.app-container.sidebar-mobile-open`) elle recouvre le header → hamburger inaccessible. Une **croix flottante** `.sidebar-mobile-close` est injectée en JS (`template.blade.php` `@push('modal')`) comme enfant direct de `.app-container` (hors du scroll `.app-sidebar__inner`), `position:fixed;top:12px;left:272px;z-index:1041` (mobile + open uniquement), clic = retire `sidebar-mobile-open` + hamburger `is-active` (miroir de `menu-map.js`). ⚠ croix masquée par défaut (`display:none`) → visible seulement via la media query `≤991.98px` ; tester en mobile RÉEL (l'extension ne réduit pas le viewport).
- Dashboard split en 2 : Hub (welcome, quick actions, KPIs, graphique, activite) + page KPI dediee (charts leads/funnel/widget perf, liste leads)
- **Tiroir UNIFIÉ "Notifications" (2026-06-25)** : la cloche du dashboard (ancien dropdown `.btnNotification` + `core::common/notification`) est remplacée par un **tiroir off-canvas** (design `.pf-*`, overlay body-level z-index 5000). Il liste **TOUTES les notifications** de l'user, **y compris l'activité portail** (les `LeadPortalActivityNotification` sont des `DatabaseNotification`) → le tiroir portail dédié initial a été **fusionné ici** (évite la redondance : 2 tiroirs montrant les mêmes notifs portail). **Source** = `$user->notifications()` (table `core.notifications`, scope `project_id`, **morph `App\Models\User`**), hors messagerie (`Rootsud\Core\Notifications\MessagingPush`). **0 changement core** (`refreshNotification()` core no-op sans `#notifications`). **Backend** `DashboardController` : `notificationsFeed` (GET `/dashboard/notifications`, cursor 20, lues+non-lues), `markNotificationsRead` (POST `.../read` {id} ou tout), `notificationsUnreadCount`, **`static notificationMeta($n)`** (normalise les 16 types core + fallback → {title, detail, url, cat, icon, level} ; pour `LeadPortalActivityNotification` → icône/niveau par événement via `HasDashboardView::portalMetaFor($data['event_type'])` = ❤️ réaction / 🖊 signature / 📅 visite…). `HasDashboardView` conserve `portalEventMeta()`/`portalMetaFor()` (réutilisés ici) + `portalProjectId()`. **Frontend** : `dashboard/_notifications_drawer.blade.php` (ID `#notificationsDrawer`) — **chips carrousel** `.pf-chips-carousel` par CATÉGORIE (`#nfChips` data-nf-cat : Tout/Clients(portal)/Alertes(alert)/Agenda/Actualités(news)/Compte(account) ; chip « Paiements »/billing RETIRÉ 2026-06-26 — les notifs de paiement ne concernent que les admins, un agent n'en reçoit pas. La normalisation `billing` reste dans `notificationMeta` (inoffensive : si une notif paiement existait, elle s'afficherait sous « Tout »)) + **toggle discret lues/non-lues** `.pf-drawer-toggle`(#nfUnreadToggle, filtre orthogonal) + action « Tout lire » `.pf-drawer-action` ; **déclencheurs** `.nf-trigger` (badge `.nf-badge` = unread **hors messagerie**, `setBadge()` synchronise TOUS les `.nf-badge`) : (1) bouton `#nfTrigger` dans `_welcome` (dashboard), (2) item sidebar « Notifications » dans `template.blade.php` (app-wide, `<li class="sidebar-bottom-item sidebar-bottom-dropdown">` à côté d'« Aide » → la classe `sidebar-bottom-dropdown` fait que le handler global `.app-sidebar ul li` de map.js l'ignore [pas de `showLeftMenu`], et l'IIFE panel-switcher l'ignore [pas de panel associé] ; badge inline `.nf-badge.sidebar-badge.badge.badge-danger` = unread hors messagerie via `unreadNotifications()->where(project_id)->where(type!=MessagingPush)->count()`). `resources/js/notifications-drawer.js` (`window.NotificationsDrawer`, open/close/fetch/infinite-scroll, chips carrousel [updateChipsArrows], double filtre cat+unread, clic item → mark-read POST {id} + nav `/acquisition`→`spaNavigate` sur le **bon onglet** (voir ci-dessous) sinon nouvel onglet, « Tout lire » → POST sans id + badge 0, **temps réel** `window.dashboardPortalFeedPush` [appelé par `acquisition.js::refreshLeadDetail` sur `lead.portal.*`] = badge++ + prepend). SCSS partagé `_portal-feed.scss` (drawer 420px desktop / plein écran <768px, `.pf-item`/`.pf-chips-carousel`/`.pf-drawer-toggle`). **Ciblage d'onglet (2026-06-25)** : chaque notif portail porte un `tab` (data-nf-tab) = l'onglet de la fiche lead où voir la donnée liée (`portalEventMeta()[*]['tab']` : match_reaction→`matching`, offer/rental/sign→`pieces`, message→`comm`, document_uploaded→`dossier`, visite/rdv/claim/rgpd→`pilotage`). Au clic, le JS pose `localStorage['lead_detail_active_tab']=tab` AVANT `spaNavigate` → `initDetailTabs()` (acquisition.js) ouvre cet onglet au chargement (valide contre les onglets réellement présents `.ld-tabs-bar-v2 .ld-tab[data-tab]`, **fallback `pilotage`** si l'onglet n'existe pas pour ce type de lead). Clés data-tab réelles : `pilotage/estimation/pieces/comm/dossier/photos/matching` (⚠ « Documents » = `dossier`, pas `documents`). E2E validé (endpoint 200/unread, icônes+tab portail par événement [matching/pieces/comm/pilotage OK], filtre catégorie, toggle non-lues, carrousel, mark-read persisté DB, clic→SPA sur le bon onglet, ellipsis, fragment welcome sans erreur, 0 erreur console).
- **Honoraires de recherche — mandat de recherche acheteur/locataire (2026-06-04)** — ajout de **2 nouveaux `source_type`** d'honoraires : **`SOURCE_TYPE_SEARCH_BUY=2`** (Recherche achat, formulaire identique à Vente : %/fixe/paliers, pas d'ALUR) et **`SOURCE_TYPE_SEARCH_RENT=3`** (Recherche location, formulaire identique à Location + **ALUR**), constantes dans `core-city/Fr_price_evolution.php`. **2 lignes `fee_structures` + 5 items seedés EN BDD direct** (pas de migration, schema `core_city_user`, clonés de Vente/Location). Les barèmes apparaissent dans `/parametres#honoraires` (4 cartes ; `_structure_select.blade.php` boucle déjà sur toutes les structures, badge type étendu ; `_form_item`/`_display`/`_item_repartition`/`includes/label_part_*` élargis à location-like via `in_array([LOCATION, SEARCH_RENT])`). **Persistance = pipeline Estimation réutilisé** : `Lead::hasMatchingEstimation()` (core-city-user) étendu BUYER+2 / TENANT+3 → un lead acheteur/locataire auto-crée une `Estimation source_type=2/3` (price=budget) au save, honoraires **visibles kanban/stats** comme les vendeurs. **FeeController** : helper central `resolveLeadSourceType()` (BUYER→2, TENANT→3) + `isLocationLikeSourceType()` + `resolveLeadSearchBudget(Lead)` (budget/surface depuis `requirement_data` : `buyerPriceMax`/`tenantPrice`+`tenantSurfHabMax`) ; `getLeadFeeDetail` expose `is_search`/`search_budget` ; `saveLeadManualFee` accepte `surface` (optionnel, pour ALUR location) ; **arrondis `(int) round()` sur ppm/surface** (colonnes INTEGER → sinon `SQLSTATE 22P02`). `getLeadEstimationData` gardé sur SALE/LOCATION uniquement (le flux avis de valeur ignore les types recherche). **Modale `modalGenerateMandatRecherche` refondue** (`_modal_groupe5_docs.blade.php`) : le champ basique `#mrcFees` remplacé par un **bloc honoraires auto/manuel cloné de `leadEditPriceModal`** (préfixe `mrc`, classes `.ldep-*` réutilisées — **approche clone = zéro régression sur le ldep vendeur/bailleur**) + champ « budget de référence » éditable pré-rempli (suffixe €/€/mois, surface affichée pour location). Contrôleur JS autonome `mrcState` dans `client-portal-admin.js` (auto/manuel, items repeater, ALUR cap, TTC/HT). **Submit = double POST** : `/manual-fee` (persiste les honoraires sur l'Estimation 2/3) puis `/mandat-recherche/generate`. **`generateMandatRecherche`** lit les honoraires effectifs via `FeeController::computeLeadFees()` → `extraMeta['fees_amount']` → `registerPending` (registre Hoguet, n° réservé **à la génération**, déjà câblé) → `{{mandate_fees_amount}}` dans le PDF + `{{fees_breakdown}}` (table détaillée location ALUR, vide pour achat). **Visibilité section + signature (2026-06-04, complétude)** : la section « Mandat de recherche » (`_lead_mandats.blade.php` + gate `lead_detail.blade.php` `$isMandatSection`) doit inclure **BUYER ET TENANT** (`$isSearchType = $isBuyer || $isTenant`) — initialement BUYER-seul, le locataire ne voyait pas la section ni le bon bouton (routé à tort vers `ldc-quick-action-mandat`/mandat de vente). Le bouton « Générer » utilise `ld-open-mandat-recherche` pour les 2. **Signature** : `KIND_MANDAT_RECHERCHE` ajouté aux 2 mappings `$kindToTriggerEvent` (e-sign `ClientPortalPublicController::signProposal` + papier `AcquisitionController::markProposalSignedManual`) → `mandat_signed` (avance le pipeline acheteur/locataire) ET au `$isMandateKind` (scan signé classé `CATEGORY_MANDATE`). Le registre Hoguet était déjà scellé pour la recherche (`$isMandate` incluait déjà le kind). **Vérifié Chrome** : achat 580k→29 000 € (5%), location loyer 1000+50m²→1 310 € ALUR (bailleur 1235/locataire 75 plafonné), génération mandat n° 2026-00016 fees 29 000 €, section + bouton OK pour locataire (610) ET acheteur (615), leadEditPriceModal vendeur intact. **Quick-actions progression (2026-06-04)** : dans `_lead_quick_actions.blade.php` (`data-section-kind="docs"`), le bouton « Mandat recherche » est gaté sur **`!$hasActiveMandate`** (étendu à `KIND_MANDAT_RECHERCHE` ; **PAS** GESTION sinon bug bailleur) → masqué dès qu'un mandat de recherche est actif/signé, et un bouton **« Bon de visite »** (`.lbv-generate-btn`, même handler que `_lead_bon_visite`) ajouté pour les visiteurs → étape suivante. Avant signature = [Mandat recherche]+[Bon de visite] ; après = [Bon de visite]. Vérifié (615 signé → recherche masqué + bon de visite ouvre la modale, vendeur 639 inchangé). **Audit cohérence documents « one-shot » (2026-06-04)** : tous les boutons de génération de documents à instance unique se masquent/désactivent dès que le document est actif/signé, alignés sur le standard `_lead_mandats` (qui désactivait déjà via `$hasActiveMandat`). `_lead_quick_actions` : 1 requête `$activeDocKinds` (statuts pending/signed) → `$hasActiveMandate` (vente/location/recherche), `$hasActiveGestion`, `$hasActiveCompromis` ; gating AJOUTÉ sur **mandat gestion** (`!$hasActiveGestion`) et **compromis** (`!$hasActiveCompromis`) — avant, seuls vente/location l'étaient. `_lead_transactions` (compromis/bail) : bouton « Générer » désormais **disabled + tooltip** si `$hasActiveTransaction` (réutilise la collection `$transactions` déjà chargée). **PIÈGE** : NE PAS mettre GESTION dans le `$hasActiveMandate` partagé (il gate aussi le bouton location du bailleur → bailleur avec gestion active verrait sa location masquée à tort) → `$hasActiveGestion` séparé. Repeatables (lettre prospection, préavis, cautionnement, bordereau, bon de visite, message) volontairement NON gatés. Vérifié Chrome : 615 (recherche+compromis signés → recherche masqué, bon visite, transactions « Générer » disabled), 639 vendeur sans doc actif → boutons enabled, 624 bailleur OK, 0 erreur console. **Lead sans type → CTA « Définir le type de projet » (2026-06-04)** : `_lead_quick_actions.blade.php` détecte `$isTyped` (reqType ∈ SELLER/LESSOR/BUYER/TENANT) ; pour un lead **sans type** (`requirement_type` NULL/0) `!$isAbandoned`, une nouvelle section `data-section-kind="define-type"` affiche un bouton **`.ldc-quick-action-define-type`** « Définir le type de projet » → `onclick=window.toggleRequirementEdit()` (fallback `openEditLeadModal($lead->id)`) qui ouvre `modalAddLead` sur **Step 1** (4 types) comme `lead-req-edit-btn`. `toggleRequirementEdit` lit le leadId depuis `[data-section="requirement"]` (toujours rendu, même sans type). Avant, les sections Documents/Actions étant gatées sur le type, un lead non typé n'avait aucun CTA pour se qualifier. Vérifié Chrome : lead 643 (sans type) → bouton ouvre la modale Step 1 ; lead typé 615 → section absente. **Lettre prospection facultative → CTA « action d'après » (2026-06-04)** : un vendeur/bailleur au stade initial (TO_CONTACT) ne voyait que « Lettre prospection » (seule, le mandat exige ESTIMATED). Ajout : `$needsEstimation = $isSeller && !hasMatchingEstimation && !abandoned` → bouton **« Lancer l'estimation »** (`.ld-open-estimation` + `data-cadastre-id` résolu via `Fr_parcel_multiple::convertCadastreId(decodeCadastreId(multiparcel_id))`, comme le KPI strip) ou **« Définir le prix »** (`.ld-edit-price`) si pas de parcelle. Ajouté à `$hasDocs`. De plus, le bouton **mandat de vente/location** est désormais dispo dès `($current >= ESTIMATED || $lead->hasMatchingEstimation())` (avant : statut seul) → robuste si le statut n'a pas été avancé (ex. estimation faite via API sans trigger). Résultat : vendeur sans estimation = [Lettre prospection + Lancer l'estimation] ; avec estimation = [Lettre prospection + Générer mandat vente] — la lettre n'est jamais une impasse. Vérifié Chrome : 643 (vendeur sans estim) et 639 (vendeur avec estim) OK, 0 erreur console. **NB** : les leads acheteur/locataire avec Estimation 2/3 sont désormais comptés dans `getFeesStats`/kanban (honoraires de recherche = revenu prévisionnel, comportement assumé « comme les gros CRM »). **PIÈGES** : (1) `estimations.ppm`/`surface` INTEGER → arrondir ; (2) le template `mandat_recherche` (#110) utilise `{{mandate_fees_amount}}` (registre) PAS `{{fees_amount}}` ; (3) l'approche clone (mrc) évite de toucher le ldep critique ; (4) `getLeadEstimationData` ne doit exposer que SALE/LOCATION (sinon le sélecteur Vente/Location de l'avis recevrait un source_type 2/3 inexistant) ; (5) **les styles `.ldep-*` sont SCOPÉS en SCSS** sous `#leadEditPriceModal, .modal-view-price-edit` (`resources/sass/_fees.scss` ~ligne 373) → le clone `mrc` ne reçoit AUCUN style tant que **`#modalGenerateMandatRecherche` n'est pas ajouté à cette liste de sélecteurs** (rebuild `app.css` requis) ; (6) `estimations.surface`/`ppm` INTEGER → `saveLeadManualFee` arrondit (sinon le calcul ppm=price/surface float → SQLSTATE 22P02) ; (7) **`Fee::calculateFees()` renvoie `items` indexé par `fee_structure_item_id`** (clés non séquentielles) → côté JS c'est un **objet, PAS un array** → tout `.length`/`.map` échoue silencieusement. Le pré-remplissage du mode Manuel depuis le mode Auto (commun à TOUS les `.ldep-block-fees`) DOIT normaliser via `mrcNormalizeItems`/`ldepNormalizeItems` (objet→array `Object.keys().map()`) avant d'itérer — sinon la bascule Manuel affiche une ligne vide (0 €) au lieu de seeder le montant auto.
- **Libellés FR des équipements/options sur la fiche lead (2026-06-04)** — les chips `.lead-req-chip-feature .lead-req-chip-value` (section « Projet/Bien » de la fiche, rendues par `_lead_property_context.blade.php` via la boucle `$mFeatures` → `{{ $mFeatureLabels[$feat] ?? $feat }}`) affichaient des **clés camelCase brutes** (`IsAdjoining`, `needsWorks`, etc.) car la map `$featureLabels`/`$featureIcons` de `_lead_requirement.blade.php` était **incomplète** (11 entrées). Map **complétée à 30 entrées** alignées sur le formulaire d'édition `_lead_step2_criteria` (`IsAdjoining`→Bien mitoyen, `needsWorks`→Travaux à prévoir, `hasAutomaticGate`→Portail automatique, `hasFiberOptic`→Fibre optique, `IsFurnished`→Meublé, `Viabilise`→Viabilisé, etc.). Vérifié : les 17 valeurs distinctes réellement présentes en BDD (`*Features` arrays) sont toutes mappées. **⚠ PIÈGE Blade critique** : un commentaire `{{-- … --}}` placé À L'INTÉRIEUR d'un bloc `@php … @endphp` n'est PAS un commentaire — PHP l'interprète littéralement (`{{` + le texte) → erreur `syntax error, unexpected identifier "FR"` (le mot « FR » du commentaire) → **500 sur toute la fiche lead**. Dans un `@php`, utiliser `//` ou `/* */`, JAMAIS `{{-- --}}`. (Symétrique du piège connu : une directive `@php`/`@include` citée dans un `{{-- --}}` casse aussi le rendu.) NB : les toggles legacy `isDuplex`/`isNotAdjoining`/`isViabilise`… (champs séparés `{prefix}IsDuplex`) restent gérés à part dans `_lead_property_context` (pas de double-rendu : une donnée est stockée soit en champ séparé legacy, soit dans le tableau `Features`).
- **Statistiques Honoraires sur `/acquisition/kpi` (2026-06-03)** — (1) **Fusion Entonnoir + Temps par étape** : la card « Temps par étape » (`#chartAvgTimePerStage`) est SUPPRIMÉE ; son slot col-lg-4 accueille la card Honoraires. L'Entonnoir (`#chartFunnel`) gagne un bouton toggle `#kpiFunnelTimeMode` (clone de `#kpiSegmentBySource`, `.btn-sort` + `.active`) dans son `card-header` (passé en `d-flex justify-content-between`) qui bascule mesure **nombre** (défaut) ↔ **temps moyen par étape**. JS `dashboard-kpi.js` : flag `funnelTimeMode`, rendu funnel factorisé dans `renderFunnel()` (remplace les 2 blocs dupliqués initCharts/refreshKpis) — en mode temps, série = lookup `avg_hours` par `lead_status_id` depuis `self.data.avgTimePerStage` (même axe X que l'entonnoir, étapes `lost` exclues, suffixe `'h'`). `renderAvgTimePerStage()` devient **no-op DOM** (les données de temps restent stockées et consommées par `renderFunnel`) ; l'AJAX `/dashboard/chart/avg_time_per_stage` ré-render le funnel si `funnelTimeMode`. **OK car** `getConversionFunnel` ET `getAvgTimePerStage` (core-city-user HasDashboard) itèrent sur le MÊME `Lead_status::getEffectiveFor` → mêmes `lead_status_id`/couleurs. (2) **Card « Honoraires » (Réalisé vs Prévisionnel)** + **2 KPI** (`#kpiFeesRealizedKpi`/`#kpiFeesPipelineKpi`, → row #dashboardKpis passe à 8 cards). Données via `data-fees-stats` (`#dashboardKpiWrapper`) + refresh AJAX `fees_stats`. JS `renderFeesCard()` + `formatEur()` (FR `toLocaleString('fr-FR')`). (3) **`HasDashboardView::getFeesStats($userId,$period)`** (SITE, côté trait dashboard) → `{realized, pipeline, avg_ticket, won_count, realized_evolution}`, **PAS de cache** (snapshot-only = assez léger → toujours frais, un changement d'honoraires se reflète immédiatement ; le cache 5 min initial a été retiré car il créait une latence visible après modif). **Honoraires FIGÉS uniquement** : somme `Lead::getKpisAttribute()['fees']` (= la MÊME source que les cards kanban/liste : snapshot/manuel + garde-fou `hasMatchingEstimation`) → **stat ≡ kanban garanti**, PAS de calcul live (choix produit 2026-06-03 : on ne compte que les honoraires que l'agent a ouverts/sauvegardés ; un lead avec estimation valide mais honoraires non figés compte 0, comme sur la card — alors que la fiche lead affiche encore un preview live via `computeLeadFees`). `realized`=leads conclus (category `won`, créés sur la période, aligné won_count) ; `pipeline`=leads actifs (category `active`, tous temps, aligné « En cours »). **⚠ Lead-only par construction** : `whereNotNull('estimation_id')` + `kpis['fees']`=null si `!hasMatchingEstimation` → une estimation autonome (sans lead) ou un lead acheteur/locataire (ou type≠estimation) n'est JAMAIS compté. `DashboardController::kpiPage` (view `feesStats`) + `kpis` (JSON `fees_stats`). **Décision** : PAS de colonne dénormalisée `leads.fees_amount` (envisagée puis abandonnée) — à l'échelle d'un portefeuille (~100-500 leads) le calcul live caché suffit, toujours frais, zéro schéma/backfill/synchro. **Pièges** : `computeLeadFees` pivote sur `$lead->user_id`/`resolveLeadFeeGroupId` (pas `auth()`) → correct pour leads partagés ; `getFeesStats` DOIT vivre côté site (le package core-city-user ne doit pas dépendre du `FeeController` du site). **Garde-fou type estimation↔lead (2026-06-03)** : kanban card (`kanban_card.blade.php`), liste (`lead_list_item.blade.php`) ET sommes par colonne kanban affichent les honoraires via `Lead::getKpisAttribute()['fees']` / `HasLeadHub::computeLeadFeeValue()` (core-city-user) — les DEUX gatées sur **`$lead->hasMatchingEstimation()`** : un lead passé vendeur→bailleur (ou l'inverse) garde un `fees_snapshot` figé du mauvais type qui NE doit PAS s'afficher (l'estimation est masquée sur la fiche). Sans ce garde-fou, le snapshot vénal fuitait sur un lead bailleur. La donnée snapshot reste en base (non détruite), seul l'affichage est filtré. Cohérent avec `computeLeadFees`/`getFeesStats` qui renvoient déjà null/0 si `!hasMatchingEstimation`. NB : le PRIX estimation n'est PAS gaté (hors scope demandé) — un prix de vente peut encore s'afficher sur un lead bailleur si besoin de re-gater. **(4) Rafraîchissement des agrégats après édition d'un lead (2026-06-03)** : (a) **Stats** — `Acquisition._showStatsView()` recharge le fragment (`_loadStats`) si `_statsDirty` (posé par `refreshLeadInView` à chaque édition de lead) OU navigation explicite (`pushState !== false`, ex. clic badge Stats) → plus de stats figées après modif (avant : `_loadStats` n'était appelé que si `!_statsLoaded`, donc une seule fois). (b) **Somme d'honoraires par colonne kanban** — la card expose `data-card-fees` (montant figé brut) et le badge colonne `data-fees-raw` (total brut) ; `refreshLeadInView()` ajuste le total via `_adjustColumnFees($col, delta)` (delta = newFee − oldFee même colonne, ou déplacement old→new colonne au changement de statut) qui met à jour `data-fees-raw` + `data-fees` formaté (arrondi dizaine, `toLocaleString('fr-FR')`) + le texte si la colonne est en mode `fees`. Sur **chargement frais** du kanban le total est déjà correct (serveur recalcule `feesTotals` via `computeLeadFeeValue`). **Piège résiduel connu** : le **drag-drop** d'une card entre colonnes met à jour le `count` (`updateColumnCounts`) mais PAS la somme d'honoraires (`data-fees-raw` non déplacé) — punt pré-existant, nécessite un refresh manuel ; les honoraires bougent rarement par drag
- **Drag-drop kanban n'ouvre plus la fiche (2026-06-03)** — `Acquisition._applyPipelineUpdate()` (acquisition.js) appelait `self.showDetail(leadId, false)` **inconditionnellement** après un changement de statut → un drag-drop cross-colonne (qui passe par `updateStatus(...,fromKanban=true)`) ouvrait la fiche du lead. Gaté sur **`!fromKanban`** : depuis un drag kanban l'utilisateur reste sur le kanban (la card est déjà déplacée par le drop handler). Le changement de statut depuis la fiche elle-même (pipeline header, `fromKanban=false`) continue de refresh la fiche. Le drag **même-colonne** (reorder) passe par `reorderLead` (pas `updateStatus`) → n'ouvrait déjà pas la fiche.
- **Carte kanban d'un lead ORPHELIN disparaît au retour-arrière (2026-06-09)** — au retour sur le pipeline depuis une fiche (back nav / breadcrumb → `onTabShown('#tab-pipeline')`), `Acquisition.refreshLeadInView(leadId)` (acquisition.js) fait un `$.get /acquisition/lead/{id}/refresh` puis, en mode **kanban**, comparait `oldStatus` (= `data-lead-status-id` de la `.kanban-column-body` où la carte est rendue) à `data.lead_status_id` renvoyé par `refreshLeadCard` (= **`$lead->lead_status_id` BRUT**). Pour un lead **orphelin/partagé** dont le `lead_status_id` brut n'est PAS dans le pipeline effectif du viewer, `kanbanData` rend la carte dans le **bucket de la 1re colonne** → `newStatusId` (brut, ex. 1547191) ne matche AUCUNE colonne (ex. pipeline 1547142–147) → branche « move » : `$oldCard.remove()` puis `.kanban-column-items[data-lead-status-id=newStatusId]` introuvable → **carte retirée sans réinsertion = disparue** (en mode **liste**, le `replaceWith` en place ne perdait jamais l'item → OK). **Fix JS** : on ne déplace QUE si la colonne cible existe ; sinon (même colonne OU **colonne cible absente** = orphelin/partagé) → `replaceWith` **en place** (la carte reste où le serveur l'a placée). Le vrai changement de statut vers une colonne réelle continue de déplacer la carte. Vérifié Chrome (leads orphelins 586/568/585/583 en colonne 1547142, refresh→1547191 hors pipeline) : clic carte→fiche→retour pipeline = **carte conservée**, 0 disparition, 0 erreur console. Rebuild `app.js`. **NB** : le correctif serveur idéal (renvoyer l'id de colonne EFFECTIF au lieu du brut) n'est pas requis — le garde JS couvre tous les cas (orphelin, partagé, bucket reçu).
- **Onglet Estimation toujours visible pour vendeur/bailleur, même sans parcelle (2026-06-03)** — `lead_detail.blade.php` : `$showEstimation = $isSellerType` (avant : `&& $lead->multiparcel_id` → l'onglet disparaissait pour un lead vendeur/bailleur dont l'adresse n'est PAS rattachée à une parcelle cadastrale, ex. adresse libre saisie dans `leadLocSection` de `modalAddLead`). `_lead_estimation.blade.php` : nouveau **fallback « sans parcelle »** (`@elseif($cadastreId)` = avis cadastral possible → bouton « Estimer » ; `@else` = pas de parcelle → bloc `.ld-estimation-noparcel` « Aucune parcelle reliée » qui explique que l'avis de valeur auto est impossible + 2 boutons modernes `btn btn-sm bg-to-light bg-to-text` : **« Définir le prix et les honoraires »** (`.ld-edit-price` → `leadEditPriceModal` ; `FeeController::getLeadFeeDetail` renvoie un stub si pas d'estimation, `saveLeadManualFee` **auto-crée** l'estimation au save avec `multiparcel_id=null` → ensuite comptée dans kanban/stats car `hasMatchingEstimation` devient vrai). **⚠ Migration requise** : `estimations.multiparcel_id` (numeric) était **NOT NULL** → l'auto-création échouait (`SQLSTATE 23502`) pour un lead sans parcelle. Rendue nullable via `core-city-user/database/migrations/2026_06_03_000001_make_estimations_multiparcel_id_nullable.php` (raw SQL `ALTER COLUMN … DROP NOT NULL`, exécutée depuis `/administration`) + **« Relier une parcelle »** (`.ld-link-parcel` → handler `acquisition.js` → `window.toggleRequirementEdit()` → `openEditLeadModal` = édition adresse/cadastre du projet). **Note** : `leads.cadastre_id` n'est PAS une colonne (`$lead->cadastre_id` = null) → pour un lead sans `multiparcel_id`, `$cadastreId` est null → branche fallback. Un lead qui a DÉJÀ une estimation matching (même si `lead.multiparcel_id` est null mais l'estimation porte un `multiparcel_id` hérité) affiche la card normale (cas lead 638). **Estimation SANS parcelle (2026-06-03)** : quand `$hasEstimation` mais `$hasParcel = !empty($cadastreId)` est faux (lead sans `multiparcel_id`), les 3 boutons header **Modifier / PDF / Envoyer** sont MASQUÉS (ils échouent « Parcelle introuvable » — le bouton PDF lit `#acquisitionDetail[data-parcel]` = `lead.multiparcel_id`, vide) et remplacés par une **info** `.ld-est-noparcel-info` (« Aucune parcelle reliée — l'avis de valeur PDF n'est pas disponible ») + bouton « Relier une parcelle » (`.ld-link-parcel`). Le bouton **« Ajuster le prix ou les honoraires »** (`.ld-edit-price`, carte) RESTE visible (saisie manuelle OK sans parcelle). **Le PDF avis de valeur sans parcelle est jugé TRÈS DIFFICILE** (≈80 % des pages dépendent du cadastre/DVF/BDNB : carte, comparables, stats marché, caractéristiques) — un PDF "estimation simplifiée" brandé serait la vraie alternative (non implémenté).
- **Pré-remplissage maximal des modals document/action de la fiche lead (2026-06-08)** — sprint « tout pré-rempli au maximum » sur les ~20 modals de génération (mandat/avenant/compromis/bail/préavis/caution/bon de visite/contre-offre/mandat recherche/close sale/lettre prospection/demande pièces/publier annonce/envoyer message/EDL). **(1) Présélection auto du CONTACT (≈9 modals)** — `loadLeadContacts`/`loadLeadContactsMulti` (`phase2-actions.js`) enrichis : `preselectRole` accepte **string OU array** (ordre de priorité), nouveau **`preselectPrimary`** (fallback `lead_contacts.is_primary` puis contact unique, **opt-in** pour ne pas présélectionner par erreur un select optionnel garant/co-loc/2ᵉ partie) et **`preselectContactId`** (priorité absolue). Helper central `window._pickPreselectContactId(contacts, opts)` (exposé, sert aussi de marqueur « nouveau bundle » en test). Rôles câblés : mandat=`['seller','lessor']`+primary, compromis vendeur=`seller`+primary / acheteur=`['buyer','tenant']` (PAS primary → évite de sélectionner le vendeur), bail bailleur=`lessor`+primary / locataire=`tenant`, préavis=rôle **inverse** (locataire→`lessor`, bailleur→`tenant`), bon visite=`['buyer','tenant']`+primary, demande pièces / lettre prospection / publier annonce / envoyer message=primary. `getContacts` renvoie déjà le pivot complet (`is_primary` boolean + `role`/`role_label`). **(2) BAIL — endpoint `GET /acquisition/lead/{id}/bail/prefill`** (`AcquisitionController::bailPrefill`, réplique `closeSalePrefill`) : loyer (estimation location `price`→requirement_data), dépôt (=1 mois), surface, `start_date` (jour). **Honoraires UNIFIÉS avec le mandat (MAJ 2026-06-08, 2ᵉ passe)** : les champs manuels `fees_tenant`/`fees_edl` de la modale bail sont **SUPPRIMÉS** et remplacés par la **`gmn-pricing-card`** (le composant « Prix & honoraires » de `modalGenerateMandat`, mêmes classes/SCSS, alimentée par `/fees` via `window.gblLoadPricing(leadId)`→`gmnLoadFees(leadId, gmnContext(4), {bodySel:'#gblPricingBody', payerSel:'#gblFeesPayer', state:gblPricingState})`) → affiche « À la charge de : Partagé · Bailleur 16 137 € · Locataire 1 547 € » (le total bailleur est **enfin visible**). L'édition passe par le bouton « Modifier » (`#gblEditPriceBtn`) → **`installPriceEditAsSubview`** (3ᵉ `modal-view.modal-view-price-edit` ajoutée au bail, conteneurs `.gbl-price-edit-body/-footer`, retour `.gbl-back-to-conditions`, sortie auto sur save via branche `_gblPriceEditActive` du handler `ajaxSuccess.p2priceEdit`) — **exactement comme mandat/closeSale** (`window.gblLoadPricing` appelé dans le trigger blade APRÈS `promoteFicheModal`+`showModal` pour cibler la copie promue, pas de race). **⚠ SCSS scoping (piège, comme le clone mrc)** : les styles `.gmn-pricing-*` sont scopés sous `#modalGenerateMandat, #modalGenerateAvenantMandat, #modalCloseSale` (`resources/sass/_fees.scss` ~l.5095) → il a fallu **ajouter `#modalGenerateBail`** à cette liste + rebuild `app.css` (sinon la card s'affiche SANS aucun style). Les styles `.ldep-*` de l'éditeur (sous-vue) couvraient déjà `.modal-view-price-edit` (~l.376) → eux OK sans modif. **Côté PDF** : `generateBail` **DÉRIVE** `lease.fees_tenant`/`lease.fees_edl` (part RECIPIENT locataire ventilée EDL vs reste) + nouveau `lease.fees_bailleur` (part OWNER = 16 137 €) depuis `computeLeadFees` (source unique, plus de lecture `$request->get('fees_tenant')`). Template #76 (`bail_location`) section 7 enrichie via SQL : ajout « Honoraires à la charge du bailleur : {{lease_fees_bailleur}} € TTC. ». Un barème mettant tout sur le bailleur → `fees_tenant=0` (correct). **Refonte ÉTAPE 1 alignée 100% sur le mandat (3ᵉ passe 2026-06-08)** : l'étape 1 « Conditions du bail » est réécrite en **3 sections `gmn-pricing-card`** (mêmes classes que le mandat) : (1) **« Loyer & honoraires »** EN PREMIER (la card pricing), (2) **« Conditions du bail »** (`gmn-cond-grid gmn-cond-grid-even` : Durée / Date début / Charges / Dépôt), (3) **« Révision du loyer (IRL) »** (`gmn-cond-grid-3` : trimestre/année/valeur + zone tendue + encadrement repliable). **Champs `rent_amount` ET `surface` SUPPRIMÉS de la modale** (déjà dans la card honoraires = source unique) → **dérivés serveur** dans `generateBail` (`$bailRent` = estimation `price` arrondi 10 → requirement_data ; `$bailSurface` = estimation→lead) + validation `rent_amount` passée en **nullable** + `bail_terms`/`lease.rent`/`lease.surface`/`asking_price` utilisent les dérivés. **SCSS** : ajout de 2 modificateurs de grille `_fees.scss` `.gmn-cond-grid.gmn-cond-grid-even {1fr 1fr}` + `.gmn-cond-grid-3 {1fr 1fr 1fr}` (additifs, NE touchent PAS le `gmn-cond-grid-2` no-op du mandat) + rebuild. **⚠ même piège SCSS** : `#modalGenerateBail` doit être dans le sélecteur groupé `.gmn-pricing-*` (~l.5097) sinon cards non stylées. **Encadrement des loyers (`gbl-zone-tendue-zone`, 4ᵉ passe 2026-06-08)** : les champs `ref_rent`/`ref_rent_max` (loyers de référence préfectoraux par quartier×pièces×époque, ~28 agglos) ne sont **PAS dans l'écosystème** → non pré-remplissables (open data Paris/etc. = intégration future possible) ; `complement_*` = décision bailleur ; `prev_rent`/dates = locataire précédent (dérivable d'un bail antérieur sur le même `multiparcel_id`, mais 0 cas en base). À la place = **outil de conformité ALUR live + UX progressive** (simplification 5ᵉ passe) : helper JS `gblUpdateEncadrementCheck()` (handler `input.gblEncadrement` sur `[name=ref_rent_max]`/`[name=ref_rent]` + appelé à l'affichage de la zone + au prefill) calcule `loyer max autorisé = ref_rent_max × surface` (surface stashée depuis le prefill dans `window._gblCtx={rent,surface}`), affiche un badge **CONFORME**/**DÉPASSEMENT** dans `.gbl-encadrement-check`. **Divulgation progressive** : par défaut seuls les 2 champs réf./réf. majoré + badge + lien source sont visibles ; la sous-zone `.gbl-complement-zone` (complément + justification obligatoire) n'est **révélée QU'EN cas de dépassement** (seul cas légal) avec montant **auto-suggéré** (= loyer − max), masquée sinon ; le loyer du précédent locataire `.gbl-prev-rent-zone` est **replié** derrière un toggle `.gbl-prev-rent-toggle` (cas relocation, optionnel) + une phrase d'intro. **PDF à jour vérifié** : les 24 vars `lease_*` du template #76 sont toutes fournies par `generateBail` ; la clause d'encadrement est gated `{{#if lease_zone_tendue}}` → pas de placeholder `……` si non-tendu. **Passage à 3 ÉTAPES (6ᵉ passe 2026-06-08)** : la modale bail a désormais **3 `modal-view`** alignées sur le mandat — **Étape 1 « Conditions »** (`modal-view-conditions` : 3 cards) → **Étape 2 « Signataires & bien »** (`modal-view-signataires` : les 4 selects bailleur/locataire/co-loc/garant + équipements, SANS `<form>`) → **Étape 3 « Comment signer »** (`modal-view-signature` : `<form id="formGenerateBail">` contenant le `_template_picker` + `_signature_delivery` (sigdel) + `#gblVerifWrap` (vérif identité OTP) + bouton Générer). Nav `gbl-next`/`gbl-back` (`data-next`/`data-back` = conditions/signataires/signature) ; le handler `click.gblNav` rappelle `SignatureDelivery.refresh($m)` à l'entrée de l'étape signature (comme le mandat). `submitBail` sérialise TOUTE la modale (`#modalGenerateBail input,select,textarea`) → les champs des 3 étapes sont captés même si le `<form>` n'enveloppe que l'étape 3 ; le `data-url` reste sur `#formGenerateBail`. Le `_bindSignerVerificationGuard` lie les selects (étape 2) à `#gblOtpRequired` (étape 3) — cross-étapes, même modale, OK. La sous-vue `modal-view-price-edit` (édition prix) reste la 4ᵉ vue, `mainViewClass:'modal-view-conditions'` inchangé. **Sélecteurs de contact Select2 + création inline (7ᵉ passe 2026-06-09)** : les 4 selects de l'étape 2 (bailleur/locataire/co-loc/garant) passent de selects plats (`loadLeadContactsMulti`) à des **Select2 `.contact-input-wrapper`** (pattern `modalAddLeadContact`) : `gblInitContactSelect2()` les initialise via `filterSelect($sel, 'contact-list', '/acquisition/lead/{id}/contact-options?role=X', {dropdownParent, allowClear:!multi})`. **Endpoint `AcquisitionController::contactOptions`** (`GET /acquisition/lead/{id}/contact-options?q=&role=`) : q vide → liste CURÉE = contacts du dossier + (role tenant/co_tenant) contacts buyer/tenant des leads matchés au bien (`Lead_proposed_match` 2 sens = locataires ayant consulté le bien) ; q → recherche TOUS les contacts du propriétaire. Format `filterSelect('contact-list')` `{id,title,avatar,text,email,phone}`. Bouton **« + »** par champ (`.gbl-add-contact[data-target][data-role]`) → 5ᵉ vue `modal-view-gbl-create` (`@include contact_form_content prefix=gbl_contact_`, `initContactFormInteractionsModal`) ; save `POST /contacts` → `new Option` injectée+sélectionnée dans le select cible (multi: push dans val) → retour étape signataires. **Auto-rattachement** : `generateBail` rattache tout signataire sélectionné (visiteur/recherché/créé) NON encore dans `lead_contacts` mais possédé par `$lead->user_id` **OU par l'acteur** (union — cohérent avec `attachContact` qui scope sur l'acteur ; gère le lead partagé où l'assigné crée un contact owned by lui), avec son rôle (lessor/tenant/co_tenant/guarantor), AVANT le check `contact_not_in_lead` (sinon 422). De même `contactOptions` recherche dans `whereIn('user_id', [owner, acteur])`. **PIÈGES** : (1) garde-fou OTP `_bindSignerVerificationGuard` lit `data-email`/`data-phone` sur l'`<option>` → au `select2:select` on recopie email/phone/title sur l'option (Select2 AJAX ne les pose pas) ; (2) `filterSelect`/select2 vivent sur la jQuery du bundle CORE — `window.jQuery` (autre instance) n'a PAS `.select2` (tester via clics DOM natifs, pas l'API select2) ; (3) `loadLeadContactsMulti` reste utilisé par `modalGenerateCaution` (ne pas supprimer) ; (4) la préselection auto du bailleur (ancien `preselectRole`) est abandonnée — la liste curée le montre en 1er clic. **Correctifs UI (2026-06-09)** : (a) **chips multi uniformes/alignés** — `templateSelection` (filterSelect contact-list) rend un chip AVEC avatar (`<div .select2-selection__avatar-title>`, ~32px) quand `repo.avatar` existe, et un chip TEXTE seul (~25px, `display:table`) sinon → dès qu'un contact SANS avatar (ex. « Marie Dupont ») rejoint un multi-select, la rangée se décale (hauteurs ≠). Fix SCSS `_form-unified.scss` dans `.contact-input-wrapper` : `.select2-selection--multiple .select2-selection__choice{display:inline-flex;align-items:center;min-height:32px}` → TOUS les chips (avec/sans avatar) à 32px sur la même rangée (vérifié 4 chips mixtes). Remplace l'ancienne règle `:has(.select2-selection__avatar-title)` (qui ne traitait QUE les chips avec avatar → décalage des chips sans avatar). **Limite** : un contact sans avatar reste sans pastille (juste nom + ×, mais aligné) — une pastille initiales en sélection exigerait de modifier `templateSelection` du core. (b) **avatar du contact créé via « + »** — `templateSelection` lit `repo.avatar` (données Select2 de l'option) ; une `new Option()` n'a pas de `repo.avatar` et **`.data('data',{avatar})` ne traverse PAS l'instance jQuery de Select2** (core ≠ site) → pour les selects SIMPLES, on pose `$sel.attr('data-name', cname)` + `window.filterSelectPreselectedAvatar[cname]=resp.contact_avatar` (le fallback map de `templateSelection`, keyé par le `data-name` DU SELECT) → avatar affiché. **Limite connue** : un contact CRÉÉ ajouté à un select MULTI (co-loc/garant) n'affiche pas son avatar (le map est keyé par le data-name du select = 1 seul, incompatible multi) ; les contacts EXISTANTS sélectionnés en multi affichent bien leur avatar (repo.avatar via l'AJAX). `resp.contact_avatar` (POST /contacts) est bien peuplé (avatar par défaut généré). **Correctifs post-génération (2026-06-09, 2)** : (c) **plus de rechargement de page** — `submitBail` faisait `location.reload()` → remplacé par `window.Acquisition.showDetail(leadId)` (leadId extrait de l'URL via `/\/lead\/(\d+)\//`) qui rafraîchit la fiche EN PLACE + ferme la modale, comme mandat/compromis/etc. (d) **triple « Bail de location »** dans l'onglet Pièces → réduit à 2 : le `<h6>` titre du `.lead-section-header` de `_lead_transactions.blade.php` (doublon avec la tête d'accordéon) est SUPPRIMÉ, on ne garde que le sous-titre légal (`$headerSub`) + le bouton Générer (vaut aussi pour le compromis). Restent : tête d'accordéon (titre) + `.lbv-card-title` (document). (e) **bloc signataires collé aux bords** — `.lbv-card` n'a pas de padding (seul `.lbv-card-header` en a) → les enfants directs `.lead-proposal-signers`/`.lead-proposal-mandate-chips` touchaient les bords ; ajout SCSS `_lead-redesign.scss` `.lbv-card > .lead-proposal-signers, > .lead-proposal-mandate-chips { padding: 0 14px }` (+ `padding-bottom:12px` signers) → contenu inset 14px (aligné sur le header). (f) **co-locataires + garants absents du PDF** — ils étaient ajoutés comme SIGNATAIRES (`Lead_proposal_signer`) mais aucune variable ne les injectait dans le CORPS du bail (seuls `contact_lessor.*`/`contact_tenant.*` existaient). `generateBail` construit désormais `lease.co_tenants` + `lease.guarantors` (format « Nom, demeurant Adresse ; … » via `$bailResolveParties`/`$bailFmtParty`, vide si aucun) ajoutées à `$bailExtraVars`. Template #76 (SQL) : section « 1. Parties » enrichie de 2 lignes gated `{{#if lease_co_tenants}}<p><strong>LE(S) CO-LOCATAIRE(S) :</strong> {{lease_co_tenants}}. …solidairement…</p>{{/if}}` + `{{#if lease_guarantors}}<p><strong>LA (LES) CAUTION(S) :</strong> {{lease_guarantors}}, …caution solidaire… (art. 22-1 loi 89-462)</p>{{/if}}`. Vars `lease_*` du bail = **26** désormais. Vérifié via réplique du parser sur le template réel : rempli → labels + noms rendus ; vide → blocs masqués, 0 placeholder résiduel (même mécanisme `extra_vars`→`template_vars` que les `lease.fees_*` déjà fonctionnels ; non testé en génération PDF complète pour ne pas envoyer d'invitations réelles). **(g) Audit conformité bail décret n°2015-587 (2026-06-09)** — comblement des mentions obligatoires du contrat-type manquantes (template #76 + `generateBail`, 10 remplacements SQL gardés + dry-run). **§2 Désignation** enrichie : `lease.habitat_type` (dérivé `requirement_data {prefix}BatiType` string : appart→« habitat collectif », maison/villa→« habitat individuel », autre→vide), `lease.year_built` (estimation `criteria.year_construct`, `\d{3,4}`), `lease.rooms` (`{prefix}Rooms`) — **tous gated `{{#if}}`** (masqués si donnée absente/inattendue → jamais de valeur fausse) + **clause neutre** (chauffage/eau chaude/accessoires/TIC/régime copro → renvoi EDL/annexes, pas d'affirmation factice). **§4** : `lease.copies_count` dynamique (= nb signataires), mention **modalités de paiement** (mensuel, d'avance, 1er du mois), **charges au forfait** (art. 23), **Travaux** depuis dernier contrat (`{{#if lease_works}}…{{else}}néant{{/if}}` → « néant » par défaut, art. 3). **§1** : « représenté par son mandataire » → « **ayant donné mandat à** » (cohérent avec la signature par le bailleur en §10) + « demeurant (ou ayant son **siège social**) » (personne morale). **§9 annexes** : ajout règlement de copropriété (le cas échéant) + état des lieux + autorisation préalable de mise en location. **Aucun blanc `……` introduit** (vérifié filled+empty, e-sign-safe). ⚠ **Opposabilité = relecture avocat humaine** (point en suspens du sprint « Audit légal templates ») — l'audit ici est structurel (présence des mentions + exactitude des citations), pas juridique. **(h) Signature dynamique + correctif citation §9 (2026-06-09, pré-envoi avocat)** — (1) **Signature multi-parties dynamique** : la table de signature figée (2 cases bailleur/locataire) du §10 est remplacée par `{{lease_signature_blocks}}`, var construite par `AcquisitionController::buildBailSignatureBlocks($parties, $electronic)` selon le **mode** : **papier** → une case de signature manuscrite par partie (bailleur + locataire + co-loc + cautions), 2 par ligne, mention « Lu et approuvé » (cellule de remblai si nb impair, `''` si 0 partie) ; **électronique** → mention eIDAS (règlement UE 910/2014) — les signatures réelles restent rendues par `{{signature_block}}` (= `MultiPartySignatureProvider::buildMultiPartySignatureBlock`, déjà multi-parties). `$bailDelivery['signature_mode']` disponible avant `$bailExtraVars`. (2) **Citation §9 corrigée** (recherche Légifrance) : la **notice d'information** relève de l'**arrêté du 29 mai 2015 modifié** (art. 3 loi 89-462, modifié arrêté 16/02/2023), PAS du **décret n°2015-1437** (= liste des pièces justificatives candidat/caution — retiré du bail). Vérifié : helper papier (4 cases/2 lignes/Lu-et-approuvé), électronique (eIDAS, 0 case), rendu complet 0 placeholder résiduel, citation OK. **Reste pour l'avocat** : format signature manuscrite >2 parties (désormais géré), périmètre = logement vide résidence principale uniquement. `#gblZoneTendue` reste auto-coché (INSEE→`Fr_insee.alur`) + hint « décochez si pas d'encadrement » (zone tendue ⊋ encadrement). ⚠ test : `jQuery.trigger('input')` synthétique ne déclenche pas toujours le handler délégué natif → tester avec un `input` natif (`dispatchEvent(new Event('input',{bubbles:true}))`). **Zone tendue (`zone_tendue`)** : `estimation->insee_alur` (`Fee::ZONE_TYPE_TENDUE`/`TRES_TENDUE`) prioritaire, sinon résolu via l'INSEE du bien = 5 premiers car. du cadastre (`Fr_parcel_multiple::convertCadastreId(decodeCadastreId(multiparcel_id), true)`) → `Fr_insee::where('code_insee')->value('alur')` (1=tendue, 2=très tendue) → coche `#gblZoneTendue` + révèle `.gbl-zone-tendue-zone`. **IRL (`irl_quarter`+`irl_year`)** : dernier indice INSEE publié = trimestre **précédent** le courant (lag publication ~mi-mois suivant), valeurs select `1er/2ᵉ/3ᵉ/4ᵉ`. **`irl_value` LAISSÉ VIDE volontairement** : la valeur de l'indice est une donnée INSEE trimestrielle non détenue dans l'écosystème (aucune table/service IRL) — pré-remplir une valeur figée risquerait une donnée légale périmée sur un bail signé ; l'agent ne saisit donc QUE l'indice (trimestre+année déjà remplis). **⚠ PIÈGE** : l'étape 1 « Conditions » du bail est **HORS de `#formGenerateBail`** (refonte 2 étapes 2026-06-05) → le JS de prefill cible **`$modal`**, pas le `<form>` (sinon `$form.find('[name=rent_amount]')` = vide). **(3) PRÉAVIS — `GET .../preavis/prefill`** : `suggested_type` dérivé du `requirement_type` (bailleur→`preavis_bailleur`, locataire→`preavis_locataire` — corrige le défaut figé « locataire »), `lease_end_date` (depuis le dernier bail généré `Lead_proposal.metadata.bail_terms` = `start_date`+`duration_years`), `asking_price` congé-vendre (mandat→estimation vente). **(4) COMPROMIS — `GET .../compromis/prefill`** : Carrez (estimation `surface`), `net_vendeur` (offre acceptée→estimation vente→mandat) + `fees_total` affichés dans le récap étape 6/6 (`.gcomp-summary`), `price_source` étiqueté. + **fallback serveur** dans `generateCompromis` : `{{compromis_price}}` ne reste **jamais vide** (cascade offre→estimation→mandat via `netVendeurFromGross`). **(5) Mandat recherche** — `FeeController::buildSearchCriteriaSummary(Lead)` (100% local, noms de villes dans `requirement_data.city`) → `criteria_summary` exposé dans `getLeadFeeDetail` (`/fees`), pré-remplit `.mrc-criteria` (« Recherche : Maison, entre X € et Y €, jusqu'à Z m², N pièces… / Secteurs : … »). **(6) Petits trous** : caution `#gcaRent` (loyer estimation), contre-offre `#cofAmount` (prix attendu estimation) + `#cofExpiresAt` (J+7), bon de visite `#gbvPrice` (prix de l'estimation du bien matché — `getVisitProperties` renvoie désormais `price`), close-sale `#csaNotary` (depuis compromis `metadata.template_vars['compromis.notaire_name']`), envoyer-message amorce « Bonjour {prénom}, » (email sans template), EDL entrée trame standard 6 pièces (`buildRoomBlock(idx, name)` + `EDL_STANDARD_ROOMS`). **Vérifié E2E Chrome** (vendeur 643 / bailleur 624 / acheteur 615 + tinker 4 types) : bail tous champs remplis, contacts présélectionnés, Carrez 207, critères composés, 0 erreur console. Déjà exemplaires (NON touchés) : `leadEditPriceModal`, `modalSendEstimation`, `modalSendPortal`, `modalCloseSale` (prefill complet), avenant (hérité du mandat), EDL **sortie** (états présélectionnés sur l'entrée), ré-édition critères.
- **Système MULTI-TYPES de baux (2026-06-09)** — le bail unique `bail_location` (logement vide) devient **8 types** via un moteur scalable. **Registre `config/lease_types.php`** = source unique (kind, label, `type_label`, `law`, `requirement_types`, `duration{unit:years/months/days, options, default, fixed, renewable}`, `deposit{months_max, default_months, forbidden}`, `blocks{irl, encadrement, furniture, charges, fees, stay_dates}`, `furnished`, `min_signers`, `order`). Types : **bail_location** (vide, 3/6 ans), **bail_meuble** (1 an, dépôt 2 mois, mobilier décret 2015-981), **bail_etudiant** (9 mois non reconductible), **bail_mobilite** (ELAN, 1-10 mois, SANS dépôt, encadrement mais pas d'IRL), **bail_commercial** (3-6-9 code com. L.145, ILC, pas d'IRL/encadrement/ALUR), **bail_professionnel** (≥6 ans, art. 57A loi 86-1290), **bail_parking** (code civil, libre), **bail_saisonnier** (code tourisme, dates de séjour). **Constantes** : `Lead_proposal::KIND_BAIL_*` + `Modele::KIND_BAIL_*` (×2) + helper **`Lead_proposal::leaseKinds()`** (les 8 — source unique des `whereIn`) + `KIND_LABELS` + `KIND_GROUPS['transactions']`. **Modale `modalGenerateBail`** : sélecteur **`#gblBailKind`** (étape 1, peuplé du registre trié par `order`) + JS **`gblApplyLeaseType($modal,leadId,kind)`** (lit `window.LEASE_TYPES=@json(config('lease_types'))`) qui repeuple la durée, montre/masque les blocs via **`[data-lease-block]`** (duration/stay_dates/charges/irl/encadrement/rent_review), gère dépôt interdit (mobilité → readonly 0), affiche le régime cité, recharge `TemplatePicker.load(kind)`. **`generateBail` type-driven** : lit `bail_kind` (validé `in:` registre), `$leaseCfg=config('lease_types.'.$kind)`, validation `duration_years` relâchée (`nullable|integer|min:1|max:99`, ex-`in:3,6`), `lease.type_label`/`lease.law`/`lease.furnished`/`lease.duration_label` (helper `bailDurationLabel` ans/mois/jours)/`lease.end_date` dérivés du registre, `$bailKind` passé à `generateSignableDoc` + reload par `$bailKind`. **7 templates PDF** (migration `core-city-user/database/migrations/2026_06_09_000001_seed_lease_type_templates.php`, #146-152, is_system/scope=system/type=1/req=4, helpers `parties()`/`furniture()`/`signature()`/`footer()` partagés) — premium auto (ajoutés à `HasModel::premiumDocKinds()`+`documentTitleMap()`). **12 points d'extension généralisés** via `leaseKinds()` : `Lead_status_trigger::checkBailSigned` (pipeline `bail_signed`), 3 mappings `$kindToTriggerEvent` (Acquisition + ClientPortalPublic ×2), `HasClientPortal` (`signedBail`/`bailCreated`/`signableKinds`/labels), `_lead_transactions` (`$kindsToList`=leaseKinds pour lessor/tenant, `$kindLabels`=KIND_LABELS, libellés génériques), `_lead_hero_nba` (`signatureKindsMap['bail_signed']`), `config/document_kinds.php` (7 entrées Transactionnel), `setting/tabs/templates.blade.php` (éditeur perso). **Vérifié** : 8 kinds résolvent un template (resolveForKind), 7 templates rendent sans orphelin `{{}}` ni blanc `……`, modale E2E (meublé→1 an/dépôt 2 mois, mobilité→dépôt readonly 0/IRL masqué, commercial→carte révision masquée/9-10-12 ans, saisonnier→dates de séjour), 0 erreur console. **⚠ relecture avocat** pour l'opposabilité de chaque template (structurellement conformes au régime, citations posées). **Rendu PDF RÉEL vérifié** : appel direct `pdf($modeleId,$user,$site,$lead,false,$extraVars)` sur les 7 templates → **vrais PDF** (`%PDF`, `application/pdf`, ~150-157 KO, cover premium incluse), **0 exception** (le maillon DomPDF, le plus risqué, est prouvé). Seul reste non-exercé le POST HTTP `generateBail` complet (créerait proposal/signataires/invitations réels) — mais sa logique est php -l clean et passe par le MÊME `pdf()` (via `generateSignableDoc`, `$storage=true`). **Câblage minimal pour AJOUTER un type** : 1 entrée registre + 1 constante KIND (×2) + 1 entrée document_kinds + 1 template PDF en base (+ l'ajouter à `leaseKinds()`). **AUDIT post-implémentation (2026-06-09) — 3 correctifs** : (1) **BUG `{{#if}}` IMBRIQUÉ** dans le helper `furniture()` (`{{#if lease_equipements}}` dans `{{#if lease_furnished}}`) → le parser NON-récursif + le **nettoyage final du moteur `HasModel` l.1461 = `preg_replace('/\{\{[a-zA-Z_0-9.]+\}\}/','')` qui NE retire PAS `{{#if}}`/`{{/if}}`/`{{else}}`** (ni `#`/espace/`/`) → balises littérales visibles dans le PDF. **Règle : JAMAIS de `{{#if}}` imbriqué dans un template.** Corrigé : `furniture()` dé-imbriqué (clause meublé inconditionnelle + `{{#if lease_equipements}}` à plat) — re-render des 4 templates meublés en base (rollback impossible : bug préexistant classe dupliquée `AddColumnProjectExtrasTable` casse `migrate:rollback`). ⚠ **Mon render-test initial masquait ce bug** (son nettoyage retirait les `{{#if}}` résiduels, contrairement au vrai moteur) → re-testé avec le **vrai regex de nettoyage** : 8 templates × {rempli, minimal} = **0 résidu `{{`**. (2+3) **2 points d'extension oubliés ajoutés** (→ 14) : `_lead_proposals.blade.php` (bloc signataires via `array_merge([...], leaseKinds())`) + icône portail `client_portal/_documents.blade.php` (🔑 pour tous les baux). Verrou de complétude e-signature `generateSignableDoc` l.7661 `if($electronic)` s'applique **uniformément** à tous les kinds (mes templates sans `……` passent). Picker E2E vérifié (`data-tplpick-kind` = bon kind par type + régime cité), 0 erreur console.
- Acquisition Leads CRM : kanban 6 colonnes (vue par defaut) + toggle vers list+map, drag-drop statut, labels colores, taches auto, notes, annonces liees (16 colonnes dont 14 benchmark), timeline enrichie (5 sources), scoring contacts, modal creation lead depuis fiche parcelle, etoile lead sur parcelles, localisation annonces sur parcelles
- **Recentrage du dropdown contact `lc-contacts-list` (2026-06-08)** — le menu ⋮ de chaque contact (`_lead_contacts.blade.php`, onglet Pilotage) ne garde QUE « Définir principal » (`lc-action-primary`, si non-principal) + « Retirer du lead » (`lc-action-remove`). **Retirés car doublons/incohérents avec les systèmes dédiés** : (1) **« Ajouter une offre »** → DÉPLACÉ dans la section **Offres** (`_lead_offers_timeline`, onglet Pièces) sous forme d'une **modale `#modalAddOffer`** (style `lead-modal-modern bg-to-slider bg-to-switch bg-to-button` identique à `modalAddLeadContact`), ouverte par le bouton header `.lhn-trigger-modal[data-target-modal="#modalAddOffer"]` (→ `promoteFicheModal`+`showModal` ; modale rendue dans le fragment lead_detail, contacts pré-rendus depuis `$lead->leadContacts`) ; submit `.ao-offer-submit` POST `/acquisition/lead/{id}/contacts/{pivotId}/offers` puis `hideModal`+`showDetail`. (2) **« Retenir » (selected)** → DÉPLACÉ sur chaque carte d'offre de la section Offres (bouton « Retenir ce candidat », **réutilise `.lc-action-status` data-status=selected** → fire toujours `contact_selected`), badge « Candidat retenu » si déjà selected. (3) **« Présélectionner »/« Écarter »** (shortlisted/rejected) → SUPPRIMÉS (tags sans automation). (4) **« Enregistrer une visite »** → SUPPRIMÉ (système canonique = Agenda + bon de visite + CR `_lead_bon_visite`) ; le **compteur/feedback reste affiché en lecture seule** dans `lc-card-metrics`. **Automation hot-2x abandonnée** (plus aucune UI ne pose `Lead_contact_visit.feedback` → `suggestOfferIfHot2x` dormante ; feedback déjà vide en base). Handlers JS morts retirés d'`acquisition.js` (`lc-action-offer`/`lc-action-visit`/`lc-offer-*`/`lc-visit-*`) ; `lc-offer-toggle` (déplie l'aperçu offres sur la card) conservé. **PIÈGE** : dans la modale, `slideToggle`/`slideUp` jQuery bloquaient (file d'animation) → ouverture par modale standard + `hideModal`, pas de slide. Vérifié Chrome (lead 525) : dropdown=2 items, modale promue en body au bon style, submit crée l'offre (6→7) puis ferme, « Retenir ce candidat » ×6, 0 erreur console.
- **Onglet « Pièces & offres » en ACCORDÉON (2026-06-08)** — l'empilement de `.ld-section-head` + `.lead-section` (beaucoup de scroll, actions dures à trouver) devient un **accordéon, une seule section ouverte à la fois**. Dans `lead_detail.blade.php` (panneau `data-tab-panel="pieces"`), chaque section est un `.ld-acc-item` : tête `.ld-section-head.ld-acc-head` (icône + titre + sous-titre + `i.ld-acc-chevron`) = toggle, corps `.ld-acc-body` repliable (contient le `@include` du partial). **Première section rendue ouverte par défaut** via flag Blade `$accFirst` (robuste aux `@if` selon `requirement_type`). JS : handler délégué **`.ld-acc-head`** (`acquisition.js`, dans `bindEvents`) ferme tous les `.ld-acc-item` du `.ld-accordion` puis ouvre le cliqué (= 1 seul ouvert). SCSS `.ld-accordion` (`_lead-redesign.scss`) : items en cartes, chevron qui pivote, `.ld-acc-body{display:none}` + `.is-open>.ld-acc-body{display:block}`. **PIÈGE double-cadre** : la neutralisation card-style des `.lead-section` (`acquisition.scss`) est scopée `#acquisitionDetailContent #acquisitionDetail > .ld-tab-panel > .lead-section` (2 IDs) → ne s'applique plus une fois la `.lead-section` imbriquée dans `.ld-acc-body` → reproduite avec **`!important`** (`background/box-shadow/border-radius/margin/padding` sur `.ld-acc-body > .lead-section`). **PIÈGE navigation** : un CTA qui scrolle/focus vers une section (Hero NBA, `data-scroll-to`, `ldFocusSection`) atterrissait sur un panneau replié → `Acquisition._smoothScrollInDetail` ET `ldFocusSection` ouvrent désormais le `.ld-acc-item` contenant la cible (close siblings) avant de scroller (sinon `:visible` filtre la cible cachée). Les modales (bail/EDL/garant) restent HORS accordéon. **PERSISTANCE refresh** : la section ouverte est sauvée en `localStorage['lead_pieces_acc_open']` (clé = `data-section` stable du partial racine — mandats/offers-timeline/…) → survit aux `refreshDetail`/`showDetail` (sinon un ajout d'offre/`Retenir`/génération de doc te ramenait sur la 1re section). `Acquisition.initPiecesAccordion()` (appelée après `initDetailTabs()` dans showDetail) restaure la section sauvée ; sans clé → défaut Blade (1re). Helper `_persistPiecesAcc($item)` appelé au clic + dans les 2 auto-open. **NB** : le partial garde son propre `.lead-section-header` (titre + boutons d'action) → léger doublon de titre avec la tête d'accordéon, assumé (pré-existant). Vérifié Chrome (lead 525 acheteur, 6 sections) : 1 seule ouverte, toggle exclusif, 0 double-cadre, auto-open au scroll vers section repliée, **section conservée après refresh** (clic Offres → showDetail → toujours Offres ; ajout d'offre 6→7 reste sur Offres), défaut=1re sans localStorage, 0 erreur console (`.insights/verif-pieces-accordion.png`).
- **Onglet Pièces — compteurs accordéon + offre toujours possible + nettoyage doublons + section Caution + Infos précontractuelles (2026-06-09)** — 5 correctifs de l'accordéon de `lead_detail.blade.php`. **(1) Compteur par tête (`.ld-acc-count`)** : chaque `.ld-acc-head` reçoit un `<span class="ld-acc-count">` (avant le `.ld-acc-chevron`, SCSS calqué sur `.match-section-count` dans `_lead-redesign.scss`) peint par **`Acquisition.syncAccordionCounts()`** (acquisition.js, appelée dans `showDetail()` après `initPiecesAccordion()` + à la fin de `lrdRefreshPartial`). Chaque partial racine expose **`data-section-count`** (+ optionnel `data-section-count-label` pour les checklists `done/total`) ; n>0 → pastille `label||n` visible, sinon masquée. Voir le contenu sans déplier. **PIÈGE** : le `replace_all` du chevron a manqué la tête « Offres » (indentation 32 vs 36 espaces car non gatée par `@if`) → span ajouté à la main. **(2) Bug « Ajouter une offre » absent** (`_lead_offers_timeline.blade.php`) : le bouton ET la modale `#modalAddOffer` étaient gatés sur `@if($offerPivots->isNotEmpty())` → un lead **sans contact rattaché** n'avait ni bouton ni modale. Gates retirés (toujours rendus) ; quand `$offerPivots` vide, la modale affiche « Aucun candidat rattaché » + bouton `onclick=window.openAddLeadContactModal($leadId, prefix)` (`data-dismiss`) + submit `disabled`. **(3) Dossier locataire ALUR nettoyé** (`_lead_dossier_locataire.blade.php`) : suppression des 4 boutons header `generate-bail-trigger`/`etat-lieux-entree-trigger`/`etat-lieux-sortie-trigger` (doublons des sections dédiées Transactions + États des lieux) + `invite-guarantor-trigger` (déplacé). Reste « Initialiser la checklist ». La vue comparaison **`@include _lead_edl_view` DÉPLACÉE** vers `_lead_etat_lieux.blade.php` (sa place — l'include est désormais **unique**). **(4) Section dédiée « Caution & garant »** (nouveau partial `_lead_caution.blade.php`, LESSOR+TENANT) : boutons `invite-guarantor-trigger` (→ `#modalInviteGuarantor`, handler inline dans `_modal_invite_guarantor` délégué `$(document)`) + `ld-open-acte-cautionnement` (→ `#modalGenerateCaution`, handler `client-portal-admin.js`) + listing des `Lead_proposal where document_kind='acte_cautionnement'` (markup `.lbv-card`). Nouvel `.ld-acc-item` dans `lead_detail` (gaté LESSOR/TENANT, après « États des lieux »). **(5) Infos précontractuelles** (`_lead_other_legal_docs.blade.php`, SELLER+LESSOR) : `'informations_precontractuelles'` ajouté à `$kindsToList` + bouton `ld-open-infos-precontract` (générateur `generateInformationsPrecontractuelles` déjà câblé) + `$kindLabels`/`$kindIcons` local. **Vérifié Chrome E2E** : vendeur 643 (Infos précontract, pas de Caution), bailleur 624 (Caution+Infos précontract), acheteur 615 (pas de Caution, compteurs Mandat 14/Compromis 3/Bons 8, offre normale), locataire 642 (Dossier ALUR = 1 bouton, 0 doublon bail/EDL/garant, Caution présente, `_lead_edl_view` unique). Pastille 24×22px à droite avant chevron, 0 erreur console. **Blade + 1 méthode JS + ~20 lignes SCSS ; rebuild app.js+app.css ; 0 migration/route/modèle.**
- **Onglet Pièces — harmonisation des en-têtes (clarté, 2026-06-09)** — audit complétude + nettoyage UX. **Complétude vérifiée** (matrice par type) : tous les générateurs de documents ont leur section (avis→Estimation ; mandats vente/loc/gestion/recherche+avenants→Mandats ; infos précontract→Autres docs légaux S/L ; compromis/baux→Transactions ; offre/contre-offre→Offres ; bon visite/CR→Bons de visite ; demande pièces→Documents requis/Dossier ALUR ; EDL→États des lieux ; acte cautionnement+garant→Caution & garant ; préavis/congé/bordereau/PV→Autres docs légaux). lettre_prospection & close_sale = actions (Comm/Hero), pas des « pièces ». **Clarté** : suppression du `<h6>` titre interne dupliqué (+badge/compteur interne) de **toutes** les sections de l'accordéon (alignées sur le pattern déjà propre de `_lead_transactions`) → la tête d'accordéon porte titre+sous-titre+pastille, le corps ne garde qu'un **sous-titre légal `<small>` + les boutons d'action**. Touche `_lead_mandats`/`_lead_required_docs` (2 branches)/`_lead_dossier_locataire`/`_lead_offers_timeline`/`_lead_bon_visite`/`_lead_etat_lieux`/`_lead_caution`/`_lead_other_legal_docs`. Pastille « Profil financier » = `✓` (renseigné) au lieu de `1` (via `data-section-count-label`). Vérifié Chrome (bailleur 624, 7 sections) : 0 titre dupliqué, sous-titre+boutons par section, pastille « 1 » sur Baux, 0 erreur console — **100% Blade, view:clear (pas de rebuild)**.
- **Onglet Pièces — visibilité par type : Offres (vente only) + Profil financier déplacé (2026-06-09)** — 2 sections mal gatées corrigées. **(1) Offres** : la section `_lead_offers_timeline` (« Offres reçues » vendeur / « Offres déposées » acheteur) était **toujours rendue** → un **locataire/bailleur** voyait « Offres déposées » alors qu'une location se loue au prix affiché (pas d'offre de prix). Gatée à **SELLER + BUYER** uniquement (`lead_detail.blade.php` section 4 : `@if(in_array(requirement_type, [SELLER, BUYER]))`).
- **Audit picker complet : ajout `counter_offer` + fix sur-filtrage `leadTemplates` (2026-06-10)** — audit exhaustif des modales fiche lead vs résolutions de template backend. **Gap trouvé** : `_modal_counter_offer` (contre-proposition, génère `offre_achat` doc signable) n'avait PAS le picker alors que `generateCounterOffer` passait DÉJÀ `template_id` à `resolveDocumentTemplate` (backend prêt). Ajouté : picker `@include(_template_picker, ['ns'=>'cof','kind'=>'offre_achat'])` + `TemplatePicker.load($modal, leadId, 'offre_achat')` (sans canal = doc signable) dans le handler `click.openCounter` + `template_id` dans le payload `cofSubmit` (`client-portal-admin.js`). **Bug RACINE corrigé** : `leadTemplates` (endpoint `/templates`) passait `$lead->requirement_type` à `availableForKind` pour TOUS les kinds → cassait les kinds doc dont le template est tagué d'un type ≠ lead (ex. **`offre_achat` est `req=1` buyer mais généré en contre-offre sur un lead vendeur `req=2`** → template système 77 masqué ; idem bail `req=4` sur lead tenant `req=3`, compromis `req=2` sur lead buyer). Or le backend `resolveDocumentTemplate` résout en **`req=null`** (les kinds doc sont type-spécifiques par KIND : mandat_vente ≠ mandat_location). Fix : `leadTemplates` ne filtre par type **QUE pour `avis_valeur`** (seul kind à 2 variantes email vendeur/location sous le même kind, cohérent avec son `resolveForKind` req-aware) ; `requirement_type=null` pour tous les autres → cohérent avec `resolveDocumentTemplate`. Vérifié : counter_offer `[157 perso, 77 sys]`, avis vendeur→`[46]` / bailleur→`[153]` (non-régression). **Conclusion audit** : les **14 modales génératrices** ont le picker (9 historiques + avis + close_sale + send_portal + invite_guarantor + counter_offer). **`generateInformationsPrecontractuelles` converti en modal (2026-06-10, suite)** : l'ancienne génération directe 1-clic (`.ld-open-infos-precontract` → POST direct) est remplacée par une **modal explicative** `_modal_infos_precontractuelles.blade.php` (`#modalInfosPrecontractuelles`, incluse body-level dans `page_map_container`) qui (a) **explique l'utilité du document** (« À quoi sert ce document ? » + ce qu'il précise : prestation/prix/identité Hoguet/droit de rétractation 14 j + encart légal art. L.111-1 & L.221-5 Code conso + devoir de conseil Hoguet) et (b) porte le **sélecteur de modèle** (`ns='ipc'`, kind `informations_precontractuelles`, pick_type=1 doc). JS (`client-portal-admin.js`) : handler `click.openInfosPre` ouvre la modal + `TemplatePicker.load` (sans canal) ; nouveau handler `click.ipcSubmit` POST `template_id` → `download_url` (backend `generateInformationsPrecontractuelles` acceptait déjà `template_id`, 0 modif). Vérifié E2E (fork test) : modal + explication + picker `[Perso, Système]` défaut Perso, 0 erreur console. `recordSru` (enregistrement, pas de doc) / `etat_lieux` (capture données) / `groupe6_comm` (message libre) / `bulkMatchAction` (avis en masse, pas de sélection par item) = sans template ou hors périmètre picker.
- **Sélecteur de modèle (`_template_picker`) étendu à close_sale / send_portal / invite_guarantor (2026-06-10)** — suite de l'avis : 3 modales supplémentaires gagnent le picker (fork user vs système, défaut = fork). **`_modal_close_sale`** (`csa`, kind `close_sale_seller`, email-only) : picker après la section Finalisation, `TemplatePicker.load(...,'email')` à l'ouverture (`phase2-actions.js` handler `p2cs`), `template_id` appendé au `$form.serialize()` du submit `p2cssub` ; backend `closeSale` → `Modele::resolveForKind(KIND_CLOSE_SALE_SELLER, [...,'requirement_type'=>null,...], template_id)` (**`requirement_type=null` voulu** : le template système 87 est tagué vendeur `req=2` mais closeSale n'est pas réservé aux vendeurs → superset sûr, 0 régression pour un lead LESSOR). **`_modal_send_portal`** (`spm`, kind `portal_link_invitation`, **email-only** car le SMS portail est historiquement stocké en `TYPE_DOCUMENT`=1, hors périmètre picker) : picker avant la section Canal (`client-portal-admin.js` `openSendPortalModal` load email), **re-fetch de l'aperçu email** au changement (`change.spmTplPick` → `GET /portal/{tokenId}/preview-send?template_id=`), `template_id` au submit `spmSubmit` ; backend `ClientPortalController::previewSend`+`sendToClient` → email via `resolveForKind` (remplace l'ancien `Modele::find($modele_id)` non validé + ajoute la validation `template_id`), SMS inchangé (TYPE_DOCUMENT). **`_modal_invite_guarantor`** (`inv`, kind `portal_link_invitation`, email, handler inline Blade) : picker fin du modal-body, load à l'ouverture + `template_id` au submit ; backend `inviteGuarantor` → `resolveForKind`. **⚠ PIÈGE CONFIG** : `document_kind_pick_type($kind,$channel)` mappe `email`→type 0 **uniquement si le kind a `channel_based=true` OU `pick_type=0` dans `config/document_kinds.php`** ; `close_sale_seller` et `portal_link_invitation` y étaient **ABSENTS** → défaut `pick_type=1` (document) → `/templates?channel=email` cherchait le mauvais type (close_sale renvoyait `[]`, portal renvoyait le template SMS type=1). Fix = 2 entrées ajoutées (`pick_type=0, channel_based=false`) + `config:clear`. Vérifié E2E (copies perso de test) : les 3 pickers visibles 2 candidats, défaut Perso, bascule Système, send_portal re-fetch l'aperçu, 0 erreur console. **EXCLUS À RAISON** : `_modal_etat_lieux_entree/sortie` = capture de données (`saveEtatLieux` ne génère ni PDF ni email, kind `etat_lieux` jamais utilisé) ; `_modal_groupe6_comm` (`#modalSendMessage`) = message **libre** sans kind/template (`sendLeadMessage` = `Mail::html`) → aucun template à sélectionner.
- **Sélecteur de modèle (`_template_picker`) ajouté à `_modal_send_estimation` (2026-06-10)** — la modale d'envoi de l'avis de valeur permet désormais de choisir le template (fork user vs système), comme les 9 autres modales de génération (mandat, compromis, bail, bon_visite, préavis, avenant, lettre_prospection, demande_pieces, groupe5_docs). **Composant** `@include('acquisition.partials._template_picker', ['ns'=>'sem','kind'=>'avis_valeur'])` (auto-masqué si ≤1 candidat actif, **défaut = le fork user** via ordering `availableForKind` scope user>group>system). **JS** (`client-portal-admin.js`) : `TemplatePicker.load($modal, leadId, 'avis_valeur', 'email')` à l'ouverture, **reload par canal** au changement (email/both→templates email, sms→templates sms), **re-fetch de l'aperçu** au changement de modèle/destinataire (`loadSendEstimationPreview(leadId, contactId, templateId)`), `template_id` envoyé au submit. **Backend** : `sendEstimationPreview`/`sendEstimation`/`buildEstimationSmsBody` remplacent `findForKindScoped`/requêtes inline par **`Modele::resolveForKind($kind, ['user_id','group_id','requirement_type','type'=>TYPE_EMAIL|TYPE_SMS], $templateId)`** (valide l'ownership+kind+type de l'id explicite, sinon défaut) → un id de template email ne s'applique qu'à l'email, un id sms qu'au sms (filtre par `type`). `leadTemplates` (endpoint `/templates`) passe désormais **`$lead->requirement_type`** à `availableForKind` (filtre par type de lead — sûr pour les autres kinds déjà type-tagués). **Data fix** : templates email avis re-tagués par type (46+141 → `requirement_type=2` vendeur, 153 → 4 location) — avant 46/141 étaient `NULL` (générique) et auraient faussé le défaut sur un lead location. Vérifié E2E (copie perso de test) : picker visible 2 candidats, défaut « Perso », bascule vers « Système » recharge l'aperçu, 0 erreur console. **⚠ availableForKind filtre `archived=ACTIVE`** → une copie user archivée (ex. 140) n'apparaît pas (comportement voulu). **SUITE (2026-06-10)** : picker ajouté aussi à `close_sale`, `send_portal`, `invite_guarantor` (voir note dédiée ci-dessus) ; `etat_lieux` + `groupe6_comm` exclus à raison (pas de template). Les autres modales non-génératrices (edit_price, mark_signed, publish_advert, counter_offer, add_lead_task, record_sru) n'en ont pas besoin.
- **Avis de valeur SMS : template 133 RÉPARÉ + type-aware + lien portail (2026-06-10)** — le template SMS système **133** était dormant (`sendEstimationPreview` filtrait `where('type', TYPE_DOCUMENT=1 PDF)` au lieu de `TYPE_SMS=2`, ET lisait `->html` au lieu de `->sms_body`). **Corrigé** : la preview interroge `TYPE_SMS` + lit `sms_body`. Nouveau helper **`buildEstimationSmsBody($lead,$contact)`** unifie preview ET envoi (`sendEstimation` cas champ-vidé) : rend 133 (court-lien portail si dispo, sinon SMS par défaut). Helper **`portalShortLinkForLead`** extrait de `buildDefaultEstimationSms` (court-lien tracking `/t/xxx`, mutualisé). 133.sms_body refait : `{{contact_firstname}}, votre {{#if is_lessor}}avis de valeur locative{{else}}avis de valeur{{/if}} est disponible.{{#if has_portal}} Consultez-le sur votre espace : {{portal_url}}{{else}} Contactez-moi pour en échanger.{{/if}} {{user_fullname}}` (adresse retirée car pouvait afficher un ID cadastral). Vars : `is_lessor` injecté par HasModel, `has_portal`/`contact_firstname`/`portal_url` (court-lien override) par `buildEstimationExtraVars`. `buildDefaultEstimationSms` (fallback) reste type-aware (`$avisLabel`). **⚠ PIÈGE COPIES SHADOW** : `findForKindScoped`/la requête SMS ordonnent `user_id IS NULL` → une **copie utilisateur** d'un template (ex. **140** « SMS (copie) » user 2, **141** email copie user 214294) **shadow le système** (133/46) pour cet user. Ces copies stale (identiques à l'ancien système) ont été **synchronisées** sur le contenu système (140←133, 141←46) — sinon un user avec copie ne reçoit JAMAIS les MAJ système. (Sync plutôt que delete : les copies sont référencées par d'anciens `lead_proposals.model_id` = FK.) Vérifié E2E : 624 bailleur+portail → « avis de valeur locative … Consultez-le sur votre espace : /t/… » ; 643 vendeur+portail → « avis de valeur … /t/… » ; 616 sans portail → « Contactez-moi ». 0 erreur console.
- **Avis de valeur email : bouton « Accéder à mon espace client » conditionnel (2026-06-10)** — comme le SMS donne déjà le lien portail si un espace client existe (`buildDefaultEstimationSms` : token actif → court-lien tracking), l'EMAIL avis de valeur affiche désormais un bouton « Accéder à mon espace client » **si et seulement si** un `Client_portal_token` actif existe pour le lead (l'avis y est aussi consultable). Implémenté via le parser `{{#if VAR}}` de `HasModel::replaceModelVariables` (le contrôleur `use HasModel`, donc la preview `sendEstimationPreview` le résout) : `AcquisitionController::buildEstimationExtraVars` ajoute `has_portal` (`'1'`/`''`) + `portal_url` (`TrackingService::publicUrlForUser($lead->user_id, '/client/'.$token->token)`, même lookup token que le SMS) ; templates **46 (vendeur) + 153 (location)** enrichis d'un bloc `{{#if has_portal}}<p>…</p><p style="text-align:center"><a href="{{portal_url}}" style="background:#3f6ad8;color:#fff;padding:12px 24px;border-radius:6px;…">Accéder à mon espace client</a></p>{{/if}}` (pattern bouton calqué sur le modele 64 « Espace client - Invitation ») inséré avant « Cordialement » via SQL `REPLACE`. Le user édite la preview déjà résolue → le bouton (ou son absence) part tel quel à l'envoi (override `proposal.metadata`). Vérifié : lead 624/643 (avec portail) → bouton présent + `portal_url` résolu (vrai lien `/client/…`) ; lead 616 (sans portail) → `has_portal=''` → **pas de bouton** ; modale 624 affiche le bouton cliquable, 0 erreur console. **PIÈGES** : (a) `{{#if}}` requiert `'1'`/`''` (pas bool) ; (b) bloc ajouté en SQL aux 2 colonnes `email_body_html` ET `html` (preview lit `email_body_html ?? html`) — à formaliser en migration pour la prod ; (c) parser `{{#if}}` non-récursif (OK ici, bloc plat).
- **Envoi avis de valeur (`modalSendEstimation`) : sélecteur destinataire + lead partagé + template location (2026-06-10)** — 3 correctifs sur le flux « Envoyer » (`ld-send-avis-valeur` → `modalSendEstimation`, JS `client-portal-admin.js` `openSendEstimationModal`/`loadSendEstimationPreview`/`.sem-submit-btn`) : **(1) Destinataire vide sur un lead PARTAGÉ** — `AcquisitionController::sendEstimationPreview` chargeait le lead via `->where('user_id', $userId)` (strict propriétaire) → **404** pour un assigné → `loadSendEstimationPreview.fail()` ne peuplait pas la carte destinataire (cf. lead 648, owner 214294, partagé à user 2). Fix : `Lead::sharedFor($userId)` (comme `sendEstimation` qui, lui, l'utilisait déjà). **(2) Plusieurs contacts non sélectionnables** — la modale n'affichait qu'UN destinataire (pas de choix). Ajout : helper `AcquisitionController::collectLeadRecipientContacts($lead)` (pivot `leadContacts.contact` + `contactSeller`/`contactBuyer`/`contact`, dédup par id, avec `role_label` FR) → renvoyé dans `'contacts'` du preview ; nouveau `<select class="sem-recipient-select">` dans `_modal_send_estimation.blade.php` (affiché si >1 contact via `$sel.toggle(contacts.length>1)`) ; `change` → recharge la preview avec `?contact_id=X` ; submit envoie `contact_id` (le backend `sendEstimation` l'acceptait DÉJÀ via `resolveLeadContact`, l.3950). **(3) Pas de template email pour la LOCATION** — `findForKindScoped(KIND_AVIS_VALEUR, …, requirement_type)` est typé par lead, mais il n'existait qu'un template générique « vendeur » (req NULL) → un bailleur recevait du wording vente. Ajout du modele système **id 153 « Avis de valeur locative - Email bailleur »** (`type=0` email, `requirement_type=4` LESSOR, `scope=system`, wording loyer mensuel/€mois) — sélectionné en priorité pour un bailleur car `findForKindScoped` ordonne `requirement_type IS NULL` après le ciblé. Vérifié E2E : lead 648 (partagé, bailleur) → destinataire « Julien Villegas » + sujet « Votre avis de valeur **locative** » (plus de 404/vide) ; lead 624 (4 contacts) → sélecteur visible, changement « Julien→Marie Dupont » recharge la preview ; 0 erreur console. **PIÈGES** : (a) `client-portal-admin.js` est bundlé dans `app.js` (webpack.mix.js:158) → `npm run dev` + hard-reload (cache app.js sans hash) ; (b) 2 blocs `data:{channel,...sms_body}` dans le fichier — celui de `modalSendPortal` (`/portal/{token}/send`, prefix `spm-`) ne doit PAS recevoir `contact_id` ; (c) le template 153 est seedé en SQL direct (à formaliser en migration `core-city-user` pour la prod, comme les autres templates avis).
- **Prix « / mois » des matches location (portail + fiche matching) (2026-06-09)** — (1) **Portail** (`cp-keydata-matches`, `cp-kpi-price`) : `HasClientPortal::buildKeyData` branche advert remplissait `price_suffix` (via `offer_type` RENT/LOC) mais la branche **lead** forçait `''` → un loyer de bien matché s'affichait sans « /mois ». Fix : `$extraRentalSuffix` piloté par le **`source_type` de l'estimation** (1=location, 3=recherche location → « /mois » ; 0/2=vente → rien), PAS par le `requirement_type` (un bailleur peut porter une vieille estimation de vente). (2) **Fiche lead, onglet `data-tab-panel="matching"`** — ⚠ **deux partials de carte** : `_match_lead_card` (matches PERSONNES = acheteurs/locataires, sur un lead vendeur/bailleur, prix = budget `requirement_data`) et **`_match_portfolio_card`** (matches BIENS = leads vendeur/bailleur, sur un lead acheteur/locataire). Le bug visé (kpi-price vide/faux malgré des estimations) était dans **`_match_portfolio_card`** : sa branche bailleur lisait `requirement_data['lessorPrice']` (souvent vide → « aucun », ou un budget erroné type 125 000 €) au lieu du **loyer de l'estimation**. Fix : prix prioritaire = `estimation->price` (vente OU loyer), suffixe « / mois » via `source_type` (1/3), fallback `requirement_data`. Bonus `_match_lead_card` : `$budgetSuffix` « / mois » quand le lead courant est bailleur (budget mensuel du candidat locataire). **Cohérence type lead ↔ estimation (raffiné 2026-06-09)** : on n'affiche le prix de l'estimation QUE si son `source_type` correspond au type ACTUEL du lead matché — **location** (`source_type` 1/3) pour un bailleur, **vente** (0/2) pour un vendeur. Sinon c'est une **donnée obsolète** (lead dont le type a changé — ex. ancienne estimation de vente sur un lead repassé bailleur) qui n'est plus affichée sur le lead → on ne la montre pas non plus dans le matching (fiche `_match_portfolio_card` `$estMatchesType` + portail `buildKeyData` `$extraEstMatchesType` gate `$extraPrice`). Vérifié E2E (lead 642 locataire) : 2 198 / 1 008 / 570 € / mois (estimations location), et le lead 595 (bailleur avec **estimation de vente** 500 000 €) affiche désormais **« aucun prix »** (au lieu de 500 000 €) sur la fiche ET le portail. **PIÈGES** : (1) pour un lead acheteur/locataire la carte de matching est `_match_portfolio_card`, PAS `_match_lead_card` (vérifier les 2 `@include` de `lead_matches.blade.php` : `@if($isOffer)` → lead_card = matches personnes, `@else` → portfolio_card = matches biens) ; (2) le « / mois » ET l'affichage du prix dépendent du `Estimation::source_type` (0=vente,1=location,2=recherche achat,3=recherche location), pas seulement du `requirement_type`. **AUDIT « même bug ailleurs » (2026-06-09)** : même garde (estimation affichée/utilisée uniquement si cohérente avec le type actuel, via **`Lead::hasMatchingEstimation()`** = le helper canonique) appliquée à **6 autres sites** qui exposaient un `estimation->price` obsolète : (a) **`Lead::getKpisAttribute()` (core-city-user)** — le **PRIX** du kanban/liste n'était PAS gaté (seuls les honoraires l'étaient) → un lead bailleur avec ancienne estimation de vente affichait 500 000 € ; corrigé en gatant prix+honoraires par `hasMatchingEstimation()` (vérifié tinker : lead 595 `kpis[price]` 500000→null) ; (b) **`AcquisitionController::getVisitProperties`** (prix du bien matché pour la modale bon de visite, cross-lead) ; (c) **`AcquisitionController` markers carte portefeuille** (~l.9664, cross-lead) ; (d) **`contact_leads.blade.php`** (liste leads d'un contact) ; (e) **`ClientPortalPublicController` `asking_price`** (fallback estimation du mandat signé) ; (f) **`HasLeadMatching::getOfferCriteria` (core-city-user)** — le **scoring** lui-même utilisait l'estimation obsolète comme critère prix → gaté (fallback `requirement_data`, neutre quand l'estimation est cohérente). **Déjà correctement gatés (vérifiés, non touchés)** : `HasLeadHub::computeLeadFeeValue` + honoraires `getKpisAttribute` (déjà `hasMatchingEstimation()`), et la fiche lead elle-même (KPI strip/snapshot/onglet Estimation : l'estimation n'est pas passée pour un type incohérent → la fiche de 595 n'affiche aucun 500 000 €). **PIÈGE** : `hasMatchingEstimation()` est LE point de vérité (SELLER↔vente, LESSOR↔location, BUYER↔recherche achat, TENANT↔recherche location) — l'utiliser avant tout affichage/usage cross-type d'un `estimation->price`.
- **`window.toastTo` : toast moderne cliquable (core) + nav SPA vers la fiche lead (2026-06-09)** — les notifs temps réel portail (`lead.portal.*`, reçues via Echo/Pusher dans `core/resources/js/auth.js`) affichaient un `toastr` basique. Nouvelle fonction globale **`window.toastTo(title, message, url, options)`** dans **`core/resources/js/app.js`** : toast custom (carte blanche, bord d'accent par niveau `info/success/warning/danger`, chip d'icône FontAwesome, CTA « Voir la fiche du lead », bouton fermer, auto-dismiss, dé-dup par `dedupKey`, anims CSS injectées 1×, styles inline → 0 dépendance CSS). **Cliquable** → navigue vers `url`. Le handler `auth.js` (case `default`/`lead.portal.`) appelle `toastTo(title, description, page_url, {level, cta, dedupKey})` (fallback `toastr` si `toastTo` absent). **Nav SPA** : `toastTo` au clic essaie `options.onClick` → puis le hook **`window.spaNavigate(url)`** (s'il retourne `true`) → sinon `location.assign` (rechargement complet). InfoParcelle implémente `window.spaNavigate` dans `resources/js/app.js` : parse `/acquisition/{id}` → charge le menu Acquisition si absent (`showLeftMenu(...callback...)`) puis `Acquisition.showDetail(leadId)` (qui gère son propre `pushState` vers `/acquisition/{id}` quand `fromUrl` est falsy) → **0 rechargement**. **Build** : core JS modifié → `npm run prod` (core) + **`php artisan vendor:publish --tag=core --force`** (depuis le site, copie `public/core/*` ; le manifest passe en versionné `?id=hash`) ; site `npm run dev` pour `spaNavigate`. Vérifié Chrome : 2 toasts rendus (style moderne info+warning), clic → fiche 624 en SPA (marqueur `window` survit = pas de full reload, URL pushState, `#tab-lead-detail` actif, `#acquisitionDetail[data-id=624]`), back SPA OK, 0 erreur console. **Réutilisable** par tout site : définir `window.spaNavigate(url)` (retour `true` = géré).
- **Notification agent temps réel pour TOUTES les actions portail + fix bug `TYPE_OFFER` (2026-06-09)** — l'agent reçoit une notif en direct (cloche in-app `DatabaseChannel` + **broadcast Pusher/PWA**, toujours actifs via `LeadPortalActivityNotification::via`) via `ClientPortalPublicController::notifyAgent($token, $activity, $eventType)` — qui exige une `Lead_activity` non-null. **6 actions client n'appelaient PAS notifyAgent** (les 9 autres oui : match_reaction, visit_request/accept/attendance, event_attendance, rdv, message, document_uploaded, rgpd) → ajout sur **createApplication** (dépôt dossier locataire, `rental_application`), **createOffer** (`offer_submitted`), **signProposal** (`document_signed`), **selfBooking** (`self_booking` — n'avait pas d'activité → ajoutée), **offerDecision** (`offer_decision`), **withdrawOffer** (`offer_withdrawn`). **⚠ BUG RACINE** : `Lead_activity::TYPE_OFFER` n'existe PAS (la vraie const = `TYPE_OFFER_CREATED`) ; or `Class::CONST_INEXISTANTE ?? defaut` **ne rattrape PAS** une constante de classe absente (≠ variable) → `Error` fatale **attrapée par le try/catch** → l'activité n'était JAMAIS créée (createOffer/offerDecision/withdrawOffer + mon createApplication) → `notifyAgent` retournait tôt (`$activity` null). Fix : 4× `TYPE_OFFER ?? TYPE_DOCUMENT` → **`TYPE_OFFER_CREATED`**. `LeadPortalActivityNotification` (core-city-user) : `fallbackTitle()` + `HIGH_PRIORITY_EVENTS` (email forcé) enrichis des nouveaux events (document_signed/offer_submitted/rental_application). Vérifié : dépôt dossier portail → activité créée (id 7139) + **notif agent user 2 (43→44)** « 📥 Dossier de location déposé · rental_application · lead 642 ». **PIÈGE général** : ne JAMAIS écrire `Class::MAYBE_UNDEFINED_CONST ?? x` (utiliser `defined()` ou une const sûre) — le `??` couvre les variables/offsets null, pas les constantes de classe absentes (fatal Error).
- **Bug réactions portail (`cp-match-react`) : double handler qui annulait le toggle (2026-06-09)** — sur le portail client, les boutons de réaction (❤/🤔/✖ sur les `cp-match-card`) ne restaient jamais actifs. Cause : **DEUX** handlers de clic dans `client_portal.js` — un **direct** (`btn.addEventListener`, phase **cible**) qui ajoutait `is-active`, puis un **délégué** (`document.addEventListener`, phase **bubbling**) qui voyait `wasActive=true` et le **retirait** (logique toggle-off) → net = bouton inactif à chaque clic (le POST `/match-reaction` partait quand même en 200, donc la donnée persistait mais l'UI ne reflétait rien ; le délégué appelait aussi `markEvent` avec `data-match-id` **null** sur ces boutons). **Fix** : fusion en **un seul handler délégué** (toggle radio correct + `portalPost('/match-reaction')` via `data-match-ref`), suppression du binding direct. Bonus : le délégué **survit aux re-render de `[data-cp-section="key-data"]`** (après un dépôt d'offre/dossier via `refreshPortalSection`) — l'ancien direct était perdu. `client_portal.js` est buildé par **`mix.copy`** (pas webpack) → `npm run dev` le recopie. Vérifié Chrome : clic dislike→actif (autres inactifs), bascule like, **persistance après reload** (`$currentReaction`), **fonctionne encore après refresh key-data**, 0 erreur console (hors `/favicon.ico` 404 préexistant). `/match-reaction` n'accepte que `like|maybe|dislike` (pas de cancel) → comportement radio (pas de dé-sélection).
- **Onglet Pièces — ordre métier logique des sections + auto-seed dossier ALUR (2026-06-09)** — (1) **Réordonnancement** de l'accordéon `lead_detail.blade.php` (section Pièces) dans l'ordre du workflow réel d'un pro, identique pour tous les types : **Mandat → Documents requis / Dossier ALUR → Bons de visite → Offres / Demandes → Compromis / Bail → Caution & garant → États des lieux → Autres documents légaux**. Correction clé : « Compromis / Baux » (l'engagement contractuel FINAL) était placé en position 2 juste après les Mandats → **descendu après les offres/demandes** (le contrat vient APRÈS l'offre acceptée) ; « Bons de visite » remontés avant les offres (on visite avant de se positionner) ; « Caution » avant « États des lieux ». Vérifié Chrome les 4 types : vendeur (Mandat→Docs→Offres reçues→Compromis→Autres), acheteur (Mandat rech.→Dossier acheteur→Bons→Offres déposées→Compromis), bailleur (Mandat→Docs→Demandes reçues→Bail→Caution→EDL→Autres), locataire (Mandat rech.→Dossier ALUR→Bons→Demandes déposées→Bail→Caution→EDL→Autres). Blade-only, view:clear. (2) **Auto-seed dossier ALUR à la qualification-locataire** : le boot `Lead::created` (core-city-user) ne seed les 8 pièces ALUR qu'à la création AVEC le type tenant → un lead créé sans type puis qualifié locataire ne l'avait pas. Ajout dans `AcquisitionController::updateRequirement` (bloc `$typeChanged`, `newType===TENANT`) : seed `Lead_required_document::seedTenantChecklist` **idempotent** (garde `whereIn(TENANT_CATEGORIES)->exists()`). **Réponse produit** : le dossier ALUR (solvabilité du locataire) se constitue AVANT de candidater, pas à l'acceptation d'une demande (l'acceptation = go du bailleur → étape suivante = bail). Vérifié tinker (8 pièces, idempotent). (3) **Garde-fou SOUPLE à la génération du bail (lead locataire)** : `AcquisitionController::generateBail` renvoie `{requires_dossier_confirm:true, dossier_progress:"X/8"}` (HTTP **200**, pas une erreur) si le dossier ALUR n'est pas complet ET `confirm_incomplete_dossier` absent (gate placé AVANT `$request->validate`). Le front `_modal_generate_bail.blade.php::submitBail($form, url, confirmIncomplete)` détecte ce signal dans le `success` → **`simpleSwalWithoutAjax`** (global swal du core) « Dossier locataire incomplet (X/8) — générer quand même ? » → re-soumet avec `confirm_incomplete_dossier=1`. **Non bloquant** (choix produit : hard-block réservé au légal Hoguet ; la complétude dossier = bonne pratique). Vérifié : sans confirm→200 `requires_dossier_confirm` (0/8), avec confirm→bypass vers validation (422 contacts).
- **Demandes de location (dossiers candidats) — portail + fiche (2026-06-09)** — pendant locatif des offres d'achat. **Stockage** : réutilise `Lead_contact_offer` (0 migration) ; une demande = un enregistrement sur un lead **LOCATAIRE/BAILLEUR** : `amount` = **revenus mensuels nets** (colonne NOT NULL → sert d'indicateur de solvabilité), `conditions_json` = `{_kind:'rental_application', move_in_date, profession, has_guarantor, _match_ref}`, `note` = message ; `financing_method`/`expiry_date` restent null. `my_active_offer` (`HasClientPortal`) la reflète automatiquement → le bouton portail bascule en « Dossier déposé ». **Portail (locataire)** : nouvelle modale **`_portal_application_create.blade.php`** (≠ wizard d'achat `_portal_offer_create`) — champs Standard : revenus mensuels / situation pro (CDI/CDD/Indépendant/Fonctionnaire/Étudiant/Retraité/Autre) / date d'emménagement / garant (oui-non) / message. Déclenchée par `data-cp-application-create` (le bouton de `_key_data` pour `cpPreset==='tenant'` ; buyer garde `data-cp-offer-create`). Endpoint `POST /client/{token}/application/create` → `ClientPortalPublicController::createApplication` (preset tenant only). `tenant.blade.php` inclut désormais `_portal_application_create` (au lieu de `_portal_offer_create`). Pills statut de `_key_data` passées en « Dossier » pour le locataire ; `_portal_offers_made` (« Mes dossiers déposés ») rendu tenant-aware (revenus €/mois, situation/emménagement/garant en méta, **conditions suspensives masquées** — sinon move_in_date/profession y fuitaient, wording « Dossier »/« candidature »). **Fiche lead** : nouvelle section accordéon **`_lead_applications_timeline.blade.php`** (LESSOR = « Demandes reçues » / TENANT = « Demandes déposées », `lead_detail.blade.php` section 4bis) — cartes (revenus €/mois, statut, candidat, situation, emménagement, garant, message) + **Retenir ce candidat** (réutilise `lc-action-status` → selected) + **Accepter/Refuser** (réutilise l'endpoint `offer/{id}/decision-admin`) + **ajout manuel** `#modalAddApplication` (`POST /acquisition/lead/{id}/contacts/{pivotId}/applications` → `AcquisitionController::createApplicationManual`). Vérifié E2E Chrome : portail locataire 642 → bouton « Déposer un dossier » ouvre la **nouvelle modale** (revenus/profession/emménagement/garant, **0 champ montant/financement**), dépôt → DB OK (amount=revenus, kind=rental_application) → s'affiche dans la fiche (« Demandes déposées ») + compteur tête ; ajout manuel OK ; bailleur 624 = « Demandes reçues » ; `_portal_offers_made` cohérent (0 « conditions suspensives ») ; 0 erreur console. **2 endpoints + 2 routes + 3 vues créées + 4 modifiées ; view:clear (pas de rebuild — JS inline Blade).** **(2) Profil financier** (mode de financement cash/prêt, stocké SANS préfixe dans `requirement_data.financing_method`/`financing_note`) : était une section accordéon de l'onglet Pièces gatée BUYER+TENANT → **erreur** (c'est un **critère du projet acheteur**, pas une pièce, et n'a aucun sens pour un locataire). **Intégré dans `modalAddLead` (étape critères)** : nouveau bloc `#leadFinancingSection` dans `_lead_step2_criteria.blade.php` **juste après `#leadCriteriaSection`** (carte « Profil financier » : select `#leadFinancingMethod` depuis `Lead_contact_offer::FINANCING_LABELS` + textarea `#leadFinancingNote`). Visibilité **ACHETEUR uniquement** via `updateStep2Fields()` (lead-actions.js : `if(reqType==='buyer') show('#leadFinancingSection')`). **Prefill** dans `openEditLeadModal` (`$('#leadFinancingMethod').val(rd.financing_method)`, sans préfixe). **Collecte** dans `submitAddLeadForm` (`if(requirementType==='buyer'){ reqData.financing_method=...; reqData.financing_note=... }` — toujours envoyées, vides si effacé → payload autoritaire). Persistance : `createLeadFromModal` (création) + `updateRequirement` (édition) stockent `requirement_data` complet. **Retiré de la fiche** (Pièces ET Pilotage). Le financement pré-remplit toujours l'offre déposée côté portail (`_portal_offer_create` lit `requirement_data.financing_method`). Partial `_lead_buyer_financing` + endpoint `/financing` devenus orphelins (conservés deletion-safe). **(3) Bug d'écrasement corrigé** : `modalAddLead` reconstruit `requirement_data` de zéro et `updateRequirement` faisait `$lead->requirement_data = $reqData` (**remplacement total**) → risque d'effacer des clés « soft ». Fix défensif : `updateRequirement` réinjecte `financing_method`/`financing_note` depuis `getOriginal('requirement_data')` si absents du payload **et** si le lead reste BUYER (inerte en pratique vu que la modale envoie toujours les clés, mais filet de sécurité). Vérifié Chrome E2E : modale acheteur 615 → étape 2, bloc financement visible+prefill (pret/note), changement cash+note→submit→**persisté en base, critères intacts** (maison/580000) ; locataire 642 → bloc **masqué** ; vendeur 643 Offres reçues / bailleur 624 & locataire 642 : 0 Offres ; 0 financing sur la fiche, 0 erreur console. **Blade + 3 points JS (lead-actions.js) + 1 méthode contrôleur ; rebuild app.js + view:clear.**
- **Badge financement dans le header « Projet » (2026-06-09)** — `_lead_requirement.blade.php` : à côté de `.lead-req-type-badge` (type de projet coloré), un **badge gris clair `.lead-req-financing-badge`** affiche le **mode de financement** (label `Lead_contact_offer::FINANCING_LABELS[financing_method]`) pour l'**ACHETEUR uniquement** quand `requirement_data.financing_method` est renseigné ; la **note** (`financing_note`), si présente, est affichée en **tooltip natif** (`title`) au survol. SCSS `.lead-req-financing-badge` (`acquisition.scss`, après `.lead-req-type-badge`) = pill `border-radius:20px`, fond `--crm-bg-subtle`/#f1f3f5, texte `--crm-text-secondary`, bordure légère, icône `fa-coins`. Gate Blade `(int)$reqType === REQUIREMENT_BUYER && $finMethod`. Vérifié Chrome (615 : badge « Prêt en cours » + tooltip note à côté de « ACHETEUR » ; sans financement → pas de badge ; non-acheteur → pas de badge), 0 erreur console. **Blade + ~20 lignes SCSS ; rebuild app.css.**
- **Sprint Filtres acquisition — "Lead très chaud" dans la modal + nettoyage toggles (2026-06-01)** — les 2 toggles toolbar `acq-hot-only-toggle` et `acq-shared-cross-toggle` sont SUPPRIMÉS de `index.blade.php`. **(1) « Lead très chaud »** : nouvelle chip `acqModalState[value=hot]` en tête de la section « État du lead » de `modalAcquisitionFilters` → coule génériquement via `currentFilters.states[]` (collecte JS inchangée) vers `HasLead::applyAdvancedFilters` (core-city-user) qui gagne un **cas `'hot'`** SQL répliquant EXACTEMENT le flag legacy `Lead::badgeFlags()['hot']` : `status IN {TO_CONTACT=0, ESTIMATED=1, PROPOSAL_SENT=2}` ET `published_at` non-null ET récent (≤3 j, ≤10 j si `event_type=Fr_dpe`). Remplace l'ancien toggle client-side qui filtrait `data-hot-buyer` (concept **Hot Buyer** distinct, jamais alimenté → inopérant) ET ne faisait RIEN en vue liste (sélecteur `[data-lead-id]` absent des items liste). Désormais fonctionne en **kanban ET liste** (filtre serveur). **(2) "Partagés avec moi" / `include_shared_cross_agency` RETIRÉ de l'UI** : pas déplacé dans la modal — supprimé (toggle toolbar + aucune chip ajoutée). Raison produit : le périmètre par défaut (`HasLead::listLeadsPaginated`/`HasLeadHub::kanbanData` : `Lead::sharedFor(moi)` ∩ `group_id == agence active OR (NULL ET owner=moi)`) montre DÉJÀ tous les leads de l'agence active assignés/partagés avec l'user ; le toggle ne servait qu'au cas de niche « lead d'une AUTRE agence où je suis assigné » → jugé déroutant. Le param backend `include_shared_cross_agency` (parsé dans `AcquisitionController` index/kanban/kanbanMore) reste mais n'est jamais activé (défaut false = comportement périmètre agence inchangé). **(3) Bug fix `kanbanColumnMore`** (core-city-user) : appelle désormais `applyAdvancedFilters` (avant : « charger plus » d'une colonne ignorait states/requirement_types/bati_types/labels/period/priority/partage). **(4) Bug fix sur-affichage badge** : `Lead::badgeFlags()` exige `published_at` non-null pour `'hot'` (avant : `diffInDays(null)≈0` marquait à tort tout lead de ces statuts). **(5) Badge `kanban-flag-hot-buyer`** masqué via `@if(false && $isHotBuyer)` dans `kanban_card.blade.php` (tracking non alimenté → évitait un 2e 🔥 vide) ; `$isHotBuyer`/`data-hot-buyer`/relation `hotBuyerTasks` conservés pour réactivation future. Validé Chrome DevTools : hot 31→1 carte (kanban) et 30→1 (liste), badge aligné, 0 erreur JS.
- Module Agenda : FullCalendar 6.x (vue configurable via preferences), CRUD events, 10 types (grille coloree), lead picker autocomplete, validation front, fullscreen mode
- **RDV privé (2026-06-02)** : toggle « Rendez-vous privé » dans `agendaEventModal` (`#agendaEventPrivate`, même row que `#agendaEventAllDay` dans `_event_form.blade.php`, + hint `.agenda-private-hint`). Un RDV privé reste visible dans le **filtre équipe** comme un bloc gris « Occupé » (créneau + agent seulement) ; titre/description/adresse/lead/participants/type/actions strictement réservés au **créateur + participants équipiers** (attendees de type `User`). **Occultation CÔTÉ SERVEUR** dans `AgendaController::events()` : la closure `$formatEvent` retourne un payload tronqué (`title:'Occupé'`, gris `#9ba3af`, `editable:false`, `extendedProps` réduit à `real_id/user_id/agentName/agentAvatar/is_private:true/is_private_masked:true`) quand `$event->is_private && !$event->viewerCanSeeDetail($user->id)` — le JSON réseau lui-même ne contient AUCUN champ sensible (vérifié : 0 fuite via DevTools). `store()`/`update()`/`updateSingleOccurrence()` acceptent `is_private`. **Backend** : colonne `core_city_user.agenda_events.is_private` + helper `Agenda_event::viewerCanSeeDetail()` (core-city-user). **JS `agenda.js`** : `saveEvent` envoie `is_private`, `showEventForm` reset+prefill le toggle, `openShowModal` branche une vue minimale « Occupé » si `is_private_masked` (masque sections + boutons édition/suppression/ICS/RSVP), `eventDidMount` ajoute classe `.fc-event-private`/`.fc-event-private-masked` + cadenas, `buildTooltip` masked-aware, badge `#agendaShowPrivateBadge` pour la vue autorisée. SCSS `.fc-event-private*` + `.agenda-show-private-badge` dans `agenda.scss`. Audit confirmé : seul `events()` (équipe) expose les events d'autrui ; `exportIcs`/`checkConflict`/`searchEvents`/`stats` sont scopés `user_id`.
- Module Widgets : menu sidebar dedie `#menuOnMapContainer-btn-left-menu-widget` (place sous "Leads" dans la section Acquisition de `template.blade.php`). `WidgetController` (app) gere : page liste `widget/list.blade.php` (3 cards estimation/dpe/opportunite + toggles), pages config `widget/config.blade.php` (`@includeIf widget/_form_estimation|_form_dpe|_form_opportunite`), mutations PUT (`widgets.estimation|dpe|opportunite.update`) + `widgets.toggle`. `LoadController@widgets` (GET `/widgets/{map?}`, `SHOW_MENU='widgets'`). **Structure des 2 fragments alignee sur les autres pages sidebar** : `<div id="widgetList">` > `#block-filter` (breadcrumb 2 niveaux `Widgets` > `Configuration|<Type>`, `data-breadcrumb` JSON, `.stepper-nav` sans `data-toggle="tab"` style `market`) > `.scroll-area-auto-js > .scrollbar-container > .setting-modern-wrapper`. Sur la page config, le crumb "Widgets" (et le bouton back de la breadcrumb-bar) declenchent `trigger('click')` sur `.nav-item[href="#tab-widgets-list"]` → handler inline `loadPostContentMenu('/widgets')` (l'ancienne `.st-back-bar`/`#st-back-to-settings` a ete supprimee). Le `WidgetConfigController` et les 3 methodes `widget*` de `SettingController` ont ete absorbes/supprimes ; l'onglet Widgets de `/parametres` et les anciennes pages `/compte/widget*` ont ete retires. JS : declare dans `map.js` (showMenu), `menu-map.js` (showLeftMenu + `_menuDefaults`) et `app.js` (popstate). Les classes CSS `wl-*`/`st-widgets-intro`/`st-back-bar` restent dans `setting.scss` (bundle global). `WidgetScriptController` (`/widget/*.js`, `/widget/test`) inchange
- Parametres : panel a 9 onglets client-side (Apparence, Preferences, Taches, Templates, Documents, Honoraires, Secteurs, Avis clients, Conformite ; `particulier` = 7, sans Honoraires/Conformite) — contenu charge en POST, onglets JS show/hide. `?tab=widgets` / `#widgets` redirige vers `/widgets`. Profil deplace depuis `/compte/profil`
- **Nav onglets `/parametres` adaptative (2026-06-08)** — la barre `.st-tabs-nav#settingTabsNav` (jusqu'à 9 onglets) ne tronque plus / ne scrolle plus horizontalement quel que soit le view-mode FULL/HALF (`toggleMapVisibility`). JS inline dans `setting_list.blade.php` : `measureSettingTabs()` mesure 1× les largeurs requises (palier complet/compact, cache `nav._thr`) aux changements de **contenu** (init, `document.fonts.ready`, `setTimeout(500)`, clic d'onglet — l'actif passe en gras = plus large) ; `applySettingTabsTier()` **idempotent** choisit le palier d'après `nav._thr` + `clientWidth` et ne mute QUE si le palier change ; un `ResizeObserver` sur la nav appelle **uniquement** `applySettingTabsTier` (couvre FULL/HALF + resize, sans re-mesure). 3 paliers SCSS (`setting.scss`) : complet → `.is-compact` (padding/font réduits) → `.is-icononly` (labels masqués SAUF l'onglet **actif** + `title` natif). `overflow-x:auto` conservé en filet ultime (<~340px). **Mobile <768px** : la media query existante masque TOUS les labels (icône seule pour tous, l'état actif se lit au fond blanc) ; comme `.is-icononly .st-tab.active span{display:inline}` a une spécificité plus forte, il faut un override DANS la media query — `@media(max-width:767px){.st-tabs-nav.is-icononly .st-tab.active span{display:none}}` — sinon sous ~416px le JS applique `.is-icononly`, le label actif réapparaît, élargit la barre et fait déborder les derniers onglets (la révélation du label actif est une feature **desktop** uniquement). **PIÈGE CLÉ** : `.st-tab` avait `transition: all 0.2s` → animait padding/font entre paliers → mesures `scrollWidth` instables (mid-animation) → le ResizeObserver oscillait à l'infini (icône↔texte sans fin). Fix racine = ne transitionner QUE `background-color/color/box-shadow` (hover), JAMAIS les dimensions pilotées par les paliers. Validé Chrome desktop : complet@1100→compact@1000→icône@950, 0 oscillation, label actif toujours visible, 0 débordement, 0 erreur console (`.insights/verif-settingtabs-compact.png`).
- **`.st-tpl-row` responsive mobile `/parametres#templates` (2026-06-11)** — sur les lignes de la liste de templates (`setting/tabs/templates.blade.php`, markup JS-rendu), le cluster badges (`Défaut`/req/Système/Agence) + 4 boutons d'action écrasait le titre sur smartphone. **Markup** : icône + `.st-tpl-info` sont regroupés dans un nouveau wrapper **`.st-tpl-main`** (`flex:1 1 auto;min-width:0`, icône `flex:0 0 auto`) — desktop identique (`.st-tpl-main`=flex:1 contient icône+info, puis badges, puis actions). **Mobile** `@media (max-width:575px)` dans le `<style>` inline : `.st-tpl-row{flex-wrap:wrap}` + `.st-tpl-main{flex:1 1 100%}` (occupe TOUTE la 1ʳᵉ ligne → icône+titre toujours ensemble, titre wrappe dans `.st-tpl-info`) + `.st-tpl-badges{order:2;margin-left:auto}` + `.st-tpl-actions{order:3}` (badges+boutons sur une 2ᵉ ligne, alignés en bas à droite). **PIÈGE résolu (2026-06-11, v2)** : la 1ʳᵉ version sans wrapper, avec `.st-tpl-info{flex:1 1 auto}` directement dans la row, laissait l'**icône orpheline sur la 1ʳᵉ ligne** quand le titre était long (la *max-content* du titre dépassait la ligne → c'est `.st-tpl-info` qui wrappait sous l'icône). Le wrapper `.st-tpl-main` en `flex:1 1 100%` est un item unique qui prend toujours la ligne entière → l'icône ne peut plus se retrouver seule. **Style inline + markup JS inline (`@verbatim`) dans le blade → `view:clear` suffit, pas de rebuild webpack.** Vérifié harness 360px (titre long « Recherche active dans votre quartier (copie) ») : icône+titre même ligne, badges/actions en 2ᵉ ligne collés au bord droit.
- **Plage `digest_morning_hour` 05h–20h + pills wrappées `/parametres#preferences` (2026-06-11)** — section « Récap quotidien › Heure d'envoi » (`setting/tabs/preferences.blade.php`). **(1)** Boucle passée de `['05'..'10']` (6 options) à `range(5,20)` + `str_pad($hRaw,2,'0',STR_PAD_LEFT)` → **16 options 05h→20h**. **(2)** Classe `preference-options-6` → **`preference-options-hours`** (n'était utilisée que là) + nouveau variant SCSS dans `.setting-modern-wrapper .preference-group .preference-options.preference-options-hours` (`setting.scss`, ~l.1153) : `flex-wrap:wrap;gap:.35rem` + pills `flex:0 0 auto;min-width:2.75rem;justify-content:center` + **`.option-check{display:none}`** (le check `margin-left:auto` étirait chaque pill et créait le débordement → la sélection reste lisible via la bordure/fond `.active`). Avant : `.preference-options` en `display:flex` SANS wrap → les 6 pills débordaient déjà à droite hors écran sur mobile. Désormais ~4 pills/ligne en mobile 360px (4 lignes), aucun débordement, autant sur la colonne droite desktop. **SCSS → rebuild webpack requis (`npm run dev`) + hard-reload** ; le blade → `view:clear`. Vérifié harness 360px : 16 pills, 0 overflow (`maxRight 288 ≤ container 336`), 4×4, sélection `09h` conservée.
- **Aperçu fidèle des templates `/parametres#templates` (2026-06-04)** — la preview de la modale (`setting/partials/_modal_edit_template.blade.php` + IIFE `setting/tabs/templates.blade.php`) reflète le **rendu final par canal** : **PDF** = le **vrai PDF premium** généré serveur (`POST /parametres/templates/preview-pdf` → `SettingController::previewTemplatePdf` → core-city-user `HasModel::previewModelPdf`, Modele transient) affiché en `<iframe>` blob (cover brandée + bandeaux + footer paginé) + lien « Ouvrir en plein écran » (fallback iOS) ; **Email** = `<iframe srcdoc>` enveloppe client mail (en-tête De/Objet résolus + carte 600px + footer RGPD) via `previewTemplate` enrichi (`subject`/`from_name`/`from_email`/`footer_html`) ; **SMS** = maquette téléphone + bulle + compteur segments (`Math.ceil(len/153)`). Nouveau **bouton œil `.st-tpl-preview`** dans `st-tpl-actions` (tous templates) → modale en **mode aperçu seul** (`.tpl-preview-only` masque le form) + bouton `#tplModalEditFromPreview` « Modifier » (perso → révèle l'éditeur) / « Personnaliser » (système → duplique). Le PDF preview-only utilise le `template.html` brut (fidèle aux `.doc-section`/tables, contourne le mangling Quill) ; depuis l'éditeur le toggle `#tplModalPreviewToggle` lit le contenu Quill. **PIÈGES** : (1) `HasModel::pdf()` refactoré → `renderModelPdf()` (corps factorisé) + `previewModelPdf()` (transient, jamais `save()`) — `pdf()` inchangé ; (2) Modele transient DOIT recevoir `params=json_encode(['html'=>…])` (getParamsAttribute fait json_decode) ; (3) blob PDF via `xhrFields:{responseType:'blob'}` + `createObjectURL`/`revokeObjectURL` ; (4) email/SMS gardent le fallback client `resolveTemplate(DEMO_DATA)` si l'endpoint échoue ; (5) JS/SCSS **inline dans les Blade** (pas dans app.js) → **pas de build webpack**, juste `view:clear` ; (6) **`openPreviewForced` masque `#tplModalEditorContainer`** (le conteneur Quill `.ql-container/.ql-editor`) → `closePreviewToEditor` DOIT le ré-afficher explicitement (`$('#tplModalEditorContainer').show()`) car `applyTplTypeUI` ne remet que la `.ql-toolbar` + le wrapper `#tplModalRichWrap` — sinon retour en édition = toolbar seule, éditeur invisible.
- Parametres Style : preview interactive PDF Avis de Valeur en layout 2 pages (intro + conseiller). Page intro A4 miniature avec zones cliquables (logo upload, footer → selecteur banniere, color swatches). Page conseiller avec header gradient sync, avatar upload, edition inline nom/fonction/telephone, bio lien vers Profil, mentions legales. Mobile pills pour alterner les 2 pages. Synchro bidirectionnelle avec onglets Apparence (couleurs, banniere) et Profil (nom, telephone, fonction). Styles `.st-style-*` et `.st-style-cons-*` dans setting.scss
- **Couverture des documents dans Apparence (2026-06-03)** : la carte « Couverture des documents » (image de page de garde des mandats/compromis/baux générés, distincte de la couverture cadastrale du `stylePagesGrid`) a été **déplacée** du hub agence `/compte/equipe/{id}` (`#agencyCoverCard` supprimé de `compte/agence/manage.blade.php`) vers le **bas de l'onglet Apparence** de `/parametres`. Nouveau partial `setting/_document_cover.blade.php` (carte `#styleCoverCard` `.stcov-*`, IIFE vanilla autonome, styles inline) inclus dans `setting/tabs/style.blade.php` **hors du bloc `$hasAccess`** (réglable même sans abonnement perso, comme dans le hub) gated `$styleCoverVisible`. **Entity-aware** : `data-entity` + `data-upload-url=/parametres/style/cover` + `data-fallback-url` ; l'IIFE envoie `entity` explicitement (POST FormData `cover`+`entity` ; DELETE `?entity=`). Endpoints `SettingController::uploadStyleCover|clearStyleCover` (routes `POST|DELETE /parametres/style/cover`, `SettingController use HasMedia` + imports `Media_tree`/`Media_collection`) : scope `group` (manager) → `groups.metadata.settings.document_cover_image` ; scope `user` → `user_settings.document_cover_image` (**perso, prioritaire sur l'agence**). Stockage via `storeMedia()` (1600×560), ID Media dans le réglage. **Visibilité** `$styleCoverVisible = ($settingScope==='group') || !in_array($resolvedProMode,['particulier','prestataire_services'])` → visible pour tout mode dont le document rend une cover (agence/mandataire_reseau/independant). `list()` expose `styleCoverEffectiveUrl`/`styleCoverHasPersoOverride`/`styleCoverIsInherited`/`styleCoverAgencyUrl`/`styleCoverVisible`. Cascade PDF `HasModel::resolveDocumentBranding` **inchangée** (perso > agence > néant). `AgencyController::uploadCover|clearCover` + routes `agency.cover.*` SUPPRIMÉS. **PIÈGE** : `resolveSettingScope` met un manager en scope `group` par défaut sans `entity` explicite → la carte DOIT toujours envoyer `entity` (sinon un manager en perso écrirait dans l'agence).
- **Fix flèches carrousel thèmes Apparence (2026-06-03)** : les flèches `.st-style-theme-arrow-left/right` des `.st-style-theme-section` (onglet Apparence) disparaissaient si l'onglet **n'était pas actif au chargement** du fragment `/parametres` (scénario : dernier onglet ≠ Apparence après refresh, puis clic sur Apparence). Cause : `updateArrows()` (IIFE styleWrapper, `style.blade.php`) calcule `maxScroll = scrollWidth - clientWidth` ; panneau `display:none` au moment de l'init → `0 - 0 = 0` → la flèche droite reçoit `st-style-theme-arrow-hidden` (`0 >= -2`) et n'était jamais ré-évaluée (seul l'event `scroll` rappelait `updateArrows`). **Fix** : `ResizeObserver` sur chaque `.st-style-theme-strip` qui rappelle `updateArrows()` dès que le strip obtient une vraie largeur (passage masqué→visible) + couvre le resize fenêtre. Pattern à réutiliser pour tout init JS mesurant des dimensions dans un conteneur potentiellement `display:none` (onglets settings).
- Parametres Calendrier : integration Google Calendar (OAuth connect/sync/disconnect) + preferences affichage (vue par defaut, heures, week-ends, type/duree/rappel par defaut)
- Parametres Automatisation modernise : pipeline visuel 6 etapes depliables (dot colore/icone par statut, badge compteur), taches avec barre priorite + badges, formulaire inline contextuel par etape, how-it-works 3 etapes, templates systeme + custom
- Module Documents & Annexes : DocumentController (10 methodes, 9 routes `/documents/*`), document.js (upload avec progress bar XHR, render, annex selection modal, sortable), _document.scss (+ skeleton loaders), vues parcelle (`card_documents`) et lead (`lead_detail` section Documents), parametres annexes par defaut modernises (`setting/tabs/documents` : 2 section cards gradient, dropzone drag-and-drop avec preview fichier, champs progressifs, badges categorie colores, skeleton loaders, responsive). Les documents sont lies a une parcelle ou a un lead, les annexes par defaut sont des templates reutilisables
- **Onglet Documents de la fiche contact (`/contacts/{id}`) — agrégation par provenance (2026-06-08)** : `DocumentController::contactDocuments($contactId)` renvoie désormais des **`sections`** (en plus de `documents`+`count`, rétro-compat) : (1) « Documents du contact » = docs propres (`contact_id`, `lead_id IS NULL`, supprimables) ; (2) **une section par LEAD où ce contact est le contact PRINCIPAL** (label = `adresse_complete`, lecture seule). « Principal » = `lead_contacts.is_primary=true` **OU** legacy `leads.contact_id` (un contact à rôle secondaire — notaire, diagnostiqueur… — ne voit PAS les docs du lead). On ne prend QUE les docs **attachés au lead** (`lead_id`), **JAMAIS** `multiparcel_id` (une parcelle partagée porte des centaines de docs d'autres leads/estimations → noierait la liste + fuite cross-lead ; les avis de valeur du lead sont bien en `lead_id`). Dédup via `$seen`. Front `contact.js::loadContactDocuments` (exposé `window.loadContactDocuments`) → si `response.sections`, rend via nouveau `renderContactDocumentSections()` qui réutilise le markup de la fiche lead (`.lead-doc-section`/`-section-title`/`-category`/`-category-label`, classes **globales** dans `_document.scss`) + `window.renderDocumentItem(doc,{canDelete: section.key==='contact'})` ; fallback plat sinon. **Suppression** : le handler global `.doc-delete` (`document.js`) détecte le conteneur `#contactDocumentsContainer` et appelle `window.loadContactDocuments(cid)` pour recharger (recalcule sections + badges `#contactDocCount`/`#contactDocTabBadge` + badge de section + empty state) — l'ancien chemin `.doc-section .doc-count` ne matche pas la structure `.ct-section-*` du contact (sinon le badge ne se décrémentait pas). Query backend optimisée (1 requête `whereIn`+`groupBy` au lieu de N+1). Vérifié Chrome : contact 159 (principal sur 17 leads) → 18 sections/46 docs catégorisés, docs lead en lecture seule, docs contact supprimables ; contrôle négatif contact 4 (notaire) → 0 doc de lead. **PIÈGE** : ne JAMAIS inclure `multiparcel_id` (avant fix : 544 docs dont 494 sur 1 lead).
- **Toggle "annexage par défaut" par annexe (2026-06-04)** — chaque `st-doc-item` de `#defaultDocList` (`setting/tabs/documents.blade.php`) porte un bouton toggle **trombone** `.st-doc-toggle-annex` (vert=actif/gris=inactif, `is-active`) à côté des `st-doc-action-btn` (rendu si `canEdit`). Il pilote le flag **`auto_attach`** (core-city-user, colonne `user_documents.auto_attach` DEFAULT true) via `POST /documents/{id}/auto-attach` (`DocumentController::toggleAutoAttach`, optimistic UI + rollback). Dans `#tab-annexes` (avis de valeur, `tab-estim-annexes.js`), `doc._checked = (doc.auto_attach !== false)` au lieu de `true` inconditionnel → une annexe par défaut peut désormais arriver **décochée** (mais reste listée, cochable manuellement). **⚠ DISTINCTION** : `is_default_annex` = présence dans la bibliothèque (#defaultDocList + #tab-annexes) ; `auto_attach` = pré-coché. `formatDocument` expose `auto_attach`. **Drag-drop de réordonnancement RETIRÉ du tab settings** (handle `.st-doc-drag` + init `handleSortable` + POST `/documents/defaults/reorder` supprimés de la vue ; SCSS `.st-doc-drag`/`.st-doc-skeleton-grip` retirés ; `.st-doc-actions` ne fade plus en bloc → seuls `.st-doc-action-view/-delete` sont en opacity 0.4 au repos, le toggle reste toujours visible). Backend reorder **conservé** (route + `reorderDefaults` + `updateDefaultAnnexOrder` + colonne `sort_order` qui sert encore à l'ordre d'affichage partout) ; le dead-code `document.js::initDefaultAnnexesSortable` (jamais appelé) référence encore la route sans impact
- **Visibilité des documents sur le portail client — toggle œil `portal_visible` (2026-06-09)** — maîtrise de ce qui apparaît dans la section Documents du **portail client** (avant : `HasClientPortal::buildDocumentsSection` exposait TOUS les docs du lead sans filtre — bug). Colonne unique `user_documents.portal_visible` (core-city-user, DEFAULT false, INDÉPENDANTE de `auto_attach`), pilotée par **2 toggles œil** + endpoint partagé `POST /documents/{id}/portal-visible` (`DocumentController::togglePortalVisible`, clone de `toggleAutoAttach` → `HasDocument::setPortalVisible`, même garde scope perso/agence ; retour `{success, portal_visible}`). **(1) Fiche lead** (`document.js renderDocumentItem` + `acquisition.js`) : icône œil cliquable (`.doc-portal-toggle`, vert `is-on`=visible / gris=masqué) sur **TOUTES les sections** du dossier — Documents du lead **+ de la parcelle + des contacts** (`portalToggle: true`, l'onglet agrège ces 3 sources) ; chaque doc porte AUSSI un **badge label** `.doc-portal-badge` (« Visible portail » vert / « Masqué » gris) dans la meta (rendu si `portalBadge || portalToggle`), **synchronisé** avec le toggle via `paint()` (badge = label clair, toggle = action) ; optimistic UI + rollback ; l'upload manuel est INVISIBLE par défaut (checkbox `_lead_documents.blade.php` repurposée `name=share_with_client`→`name=portal_visible`, libellé « Visible sur le portail client », `DocumentController::upload` set la colonne + garde `metadata.share_with_client` pour la notif Phase 4 N10). **(2) Section « Documents par défaut »** en bas de l'onglet Documents (`window.loadLeadDefaultDocs` = 2e fetch `/documents/defaults`) : liste les `is_default_annex` avec un **badge visibilité LECTURE SEULE** (`.doc-portal-badge` is-on/is-off) + lien `.lead-doc-defaults-settings` → Paramètres ; leur visibilité se règle dans `/parametres#documents`. **(3) `/parametres#documents`** (`setting/tabs/documents.blade.php`) : 2e bouton œil `.st-doc-toggle-portal` (bleu `is-active`, distinct du trombone vert `.st-doc-toggle-annex`) à côté de l'annexe, handler JS INLINE → même endpoint ; `formatDocument` expose `portal_visible`. Wording de l'onglet mentionne le portail (intro + sous-titre + empty-state) + **légende `.st-doc-legend`** (📎 joint aux avis / 👁 visible portail). **(4) Injection portail** : `buildDocumentsSection` agrège lead + **parcelle (`multiparcel_id`) + contacts** (`portal_visible`, parcelle/contact scopés propriétaire `$lead->user_id`) + injecte les docs de paramétrage `portal_visible` du propriétaire (`$lead->user_id` ∪ agence `$lead->group_id`, `is_default:true`). **(5) Download élargi** (`ClientPortalPublicController::downloadDocument`) : un doc du lead n'est téléchargeable que si `portal_visible=true` **OU** s'il est l'avis de valeur exposé par la section estimation (= **dernier** doc `category=valuation`, exempté précisément par id — pas toute la catégorie, sinon fuite des anciens avis masqués) ; + branche docs **parcelle/contact** (`portal_visible`, propriétaire, `lead_id IS NULL`, match `multiparcel_id`/`contact_id` du lead) ; + branche docs de paramétrage `is_default_annex+portal_visible` du propriétaire (sinon 404 sur les docs visibles/injectés). **SCSS** : `.doc-portal-toggle`/`.doc-portal-badge`/`.lead-doc-defaults` (`_document.scss`), `.st-doc-toggle-portal` (`setting.scss`). Validé E2E (lead 614 + portail) : filtre 2/6, injection perso+agence, toggle persisté DB, download visible=200 / avis exempté=200 / masqués=404, 0 erreur console. **PIÈGES** : (1) le toggle fiche lead est dans `app.js` (rebuild webpack + hard-reload), celui de `/parametres` est INLINE (`view:clear` suffit) ; (2) l'avis de valeur (`category=valuation`) du bloc estimation NE doit PAS être filtré par `portal_visible` (lookup séparé non filtré dans `buildDocumentsSection`) sinon le download de l'avis casse ; (3) l'exemption download de l'avis cible le **dernier** valuation par id (pas la catégorie entière).
- **Documents du paramétrage en PJ de `modalSendMessage` (2026-06-04)** — les documents réutilisables de `/parametres#documents` sont désormais proposés en pièce jointe à l'envoi d'un email libre (modale `#modalSendMessage` / `sml-section-attachments`). **Frontend** : `smlLoadAttachments` (client-portal-admin.js) fait un double fetch `$.when(/documents/lead/{id}, /documents/defaults)` et rend 2 sections labellisées (`Documents du dossier` puis `Mes documents (paramètres)`) via helpers `smlRenderAttachDoc`/`smlAttachGroupLabel` (dédup par `seen{id}`, mêmes checkboxes `.sml-attachment-cb` → summary/submit inchangés). **Backend** : `AcquisitionController::sendLeadMessage` élargit la query `attachment_ids` — accepte (1) **tout le périmètre du dossier via `getLeadDocuments($lead->id,$userId)`** (lead_id OU multiparcel_id OU contact_id du lead → `whereIn('id', $leadDocIds)` = exactement la liste affichée dans la modale), (2) annexes par défaut perso (`is_default_annex`, scope≠group), (3) annexes par défaut de l'agence (`scope=group`, `group_id=agencyIdForSettings`). **⚠ PIÈGE corrigé 2026-06-04** : la 1ère version filtrait `where('lead_id',$lead->id)` seul → un doc du dossier **lié à un contact** du lead (ex. comparatif d'annonces, `lead_id=NULL` + `contact_id` du lead) ou à la **parcelle** (`multiparcel_id`) était listé dans la modale mais **silencieusement non joint**. `getLeadDocuments` (trait `HasDocument`, déjà `use` par AcquisitionController) règle ça en réutilisant le même périmètre. **Wording** `setting/tabs/documents.blade.php` élargi : "Annexes par défaut" → "Documents réutilisables", mention "joints à vos avis de valeur **et proposés en PJ à l'envoi de vos emails**" (⚠ garder `<strong>` HORS du `{{ }}` Blade sinon échappé en texte)
- Design system CRM : `_design-tokens.scss` (35 CSS custom properties --crm-*), `_animations.scss` (12 keyframes + prefers-reduced-motion), `_empty-states.scss` (composant .crm-empty unifie avec variant compact). Importe en premier dans boot.scss
- Empty-state pill carte : container `#map-empty-alert` jumeau de `#map-zoom-alert` (meme CSS) dans `map.js:240`. Pill jaune affichee quand user sans lead/advert active la couche correspondante. Backend : trait `HasMapEmptyStates::getMapEmptyLayers()` + `View::composer('vendor')` dans `AppServiceProvider` injectant `window._mapEmptyLayersInit`. Frontend : `window.MapEmptyLayers` (core-city/overlayer/loader.js) avec `markCreated(slug)` pour invalidation temps reel. Hooks branches sur success callbacks create lead / locateAdvert dans acquisition.js, opportunity.js, lead-actions.js, parcel-tabs/tab-advert.js
- **`_lead_header_labels` réduit aux 2 boutons d'action (2026-06-09)** — le `<span class="ld-header-labels">` de la fiche lead (inclus 1× dans `lead_detail.blade.php`) ne rend plus QUE `lead-priority-toggle` (étoile priorité) + `lead-assign-btn` (assignation multi-user). **Retirés** : `lead-climate-badge` (loi Climat, le déclencheur de la demande), `lead-hot-buyer-badge`, `lead-cross-agency-badge`, `lead-url-link`, `lead-label-badge` (labels). Le `@php` ne calcule plus que `priority` + `assignees`/`avatarFor`. ⚠ Plusieurs notes ci-dessous mentionnent encore ces badges « dans `_lead_header_labels` » (climat Sprint C.2, hot-buyer Sprint C.1, cross-agency 2026-05-21) → STALE pour le rendu (la donnée/accessor `Lead::climate_warning`/`isHotBuyer`/badges kanban restent inchangés, seul l'affichage header est supprimé). Blade-only (`view:clear`).
- UX enrichie : lead detail V3 header compact + onglets intelligents (`.ld-fixed-header` sticky, `.ld-header-v2` 2 lignes : retour+badge+adresse+dropdown | chips KPI+contact+prochaine action pill+labels, pipeline inchange, 4 onglets `.ld-tab` Activite/Actions/Contexte/Dossier avec badges compteurs, persistance localStorage `lead_detail_active_tab`, `initDetailTabs()` dans acquisition.js). Navigation cross-menu Lead→Annonce via `BreadcrumbNav.setSource()` (sessionStorage `acquisition_return`). Apercu parcelle SVG `.ld-parcel-preview`. Kanban cards avec activity dot (vert/orange/rouge), badge flags, hover actions. Mobile kanban swipe (scroll-snap + dots). Slide-in/out transition detail. Dashboard KPI sparklines (ApexCharts), activity feed groupee par date
- **Refonte scroll fiche lead — pipeline pinné + tab bar sticky (2026-06-02)** : le wrapper `.ld-fixed-header` (qui figeait pipeline+Hero+KPI+tab bar) est **dissous**. Désormais dans `lead_detail.blade.php` : `#acquisitionDetail` (flex column) a 2 enfants directs → **`.ld-pipeline-row`** (1er, `flex-shrink:0` hors scroll = pinné en permanence) + **`.ld-tabs-scroll.scroll-area-auto-js > .scrollbar-container`** (scroller PerfectScrollbar unique) qui contient maintenant Hero NBA + KPI strip + **`.ld-tabs-nav.ld-tabs-bar-v2`** + les `.ld-tab-panel`. La tab bar est `position:sticky; top:0; z-index:8` → elle défile avec le contenu puis se fige juste sous le pipeline (z-index:10). **Fondu de bas** : `background:linear-gradient(to bottom, $ip-bg, $ip-bg calc(100%-16px), rgba($ip-bg,0))` + `padding-bottom:16px` (border-bottom retirée) → le contenu scrollé se dissout dans le fond sur 16px au lieu d'être collé net (fondu sur le `background`, PAS un `::after` qui serait clippé par l'`overflow-x:auto`). **Rail PerfectScrollbar** : la tab bar sticky (z-index:8) masquait `.ps__rail-y`/`x` (z-index auto=0) → règle `#acquisitionDetailContent .scrollbar-container > .ps__rail-y/x { z-index:9 }` (au-dessus de la barre, sous le pipeline z-index:10) pour garder la scrollbar visible. `position:sticky` **fonctionne dans PerfectScrollbar** (scroll natif via scrollTop, pas de transform — validé Chrome desktop/tablette/mobile). **Un seul scrollbar** (0 scroller natif, seul le `.scrollbar-container` PS scrolle). SCSS : `.ld-pipeline-row` pinné (`acquisition.scss`), `.ld-tabs-nav.ld-tabs-bar-v2` sticky (`_lead-redesign.scss`, +médias 480/sm). JS `acquisition.js` : 4 sélecteurs `.ld-fixed-header .ld-tab` → `.ld-tabs-bar-v2 .ld-tab` ; `initDetailTabs().activate()` ne fait plus un scroll-to-0 systématique mais `Math.min(scrollTop, seuilSticky)` (reste collé si la barre est figée, le nouvel onglet démarre sous la barre) ; helpers de scroll (`_smoothScrollInDetail`, save/restore, `.ldc-fees-go-estimation`) **inchangés** car le scroller `#acquisitionDetailContent .scrollbar-container` est conservé. Tous les sélecteurs de panels `> .ld-tabs-scroll .scrollbar-container > .ld-tab-panel` restent valides.
- **Carte hero "À faire maintenant" (2026-05-16)** : fusion des anciennes zones `ld-header-cta` (header CTA — SUPPRIMÉ) + `ldc-quick-next` (carte Prochaine action Contexte — SUPPRIMÉ) + `ldc-quick-automation` (conditions trigger Pilotage — SUPPRIMÉ) en **une seule carte hero** `_lead_next_step_card.blade.php` placée en tête de l'onglet **Contexte** (au-dessus de `.ld-context-grid`). La carte contient : (1) bandeau état "Statut courant → Statut cible" + barre de progression du trigger principal (sélectionné par MAX `satisfied/total` — pas de champ `priority` en DB), (2) bloc tâche/RDV prioritaire avec "Marquer fait"/"Reporter"/"Ouvrir", (3) sous-composant `_lead_status_progress.blade.php` (conditions + CTA "Faire maintenant"), (4) dropdown collapse "Voir toutes les actions disponibles" qui inclut `_lead_quick_actions` en mode `$compact`. Sélecteurs : `.lnsc-card`, `.lnsc-head`, `.lnsc-priority`, `.lnsc-conditions`, `.lnsc-more` (style dans `resources/sass/_lead-next-step.scss`). Handlers JS : `.lnsc-priority-complete` (+ alias rétro `.ldc-next-complete-task`), `.lnsc-priority-open-event`, `.ldc-auto-cta[data-cta-click-selector|data-cta-scroll-selector]` (réécrits avec helper `acquisition.ldFocusSection(selector, callback)` qui bascule sur le sous-onglet contenant la cible avant scroll/click). `_lead_quick_actions.blade.php` restructuré en 3 sections labellisées (`data-section-kind="status|docs|actions"` : Progression / Documents / Actions transverses) — toutes les classes JS (`.ldc-quick-action-*`, `.ld-open-*`, `.ldc-status-advance`) conservées. Bug latent corrigé : `_lead_tasks.blade.php:26` reçoit `id="leadTasks"` pour que le scroll-CTA `all_tasks_completed` fonctionne (le `data-section="tasks"` reste pour 3 call-sites JS existants : `client-portal-admin.js`, `agenda.js`, `acquisition.js`). ~~Limitation connue : `Lead_status_action` avec `action_kind="send_sms"` (configurable via /parametres) n'est PAS géré par `_lead_quick_actions.blade.php`~~ **LEVÉE Sprint C 2.1 + Sprint C.1 B7 (2026-05-18)** : la blade route `send_sms` vers `.ld-open-send-message` avec `data-prefer-channel="sms"` + `data-message-template-kind`, et le handler `client-portal-admin.js` lit ces attrs pour basculer la modale en mode SMS + pré-remplir le body via `GET /acquisition/lead/{id}/message-template?kind=…` (résout `Modele::findForKindScoped` + applique `replaceModelVariables` + strip HTML préservant les `\n`). Les 6 kinds (advance_status / send_email / send_sms / generate_doc / open_url / open_portal / custom) sont tous opérationnels.
- **Sprint Type de bien forcé essentiel (2026-05-27)** : le critère `type` (Maison/Appartement/Terrain/...) est désormais **toujours essentiel** quand un `BatiType` est renseigné côté lead — un acheteur de maison ne reçoit jamais de terrains dans le matching, même s'il n'a pas cliqué l'étoile. **Backend** : `HasLeadMatching::calculateMatchScore` (leads) ET `searchAdvertsForBuyer` (annonces) injectent `$levels['type'] = 'must'` si `$reqData[$prefix.'BatiType']` non vide, avant d'appeler `applyCriteriaLevels`. Le `criteria_levels` du lead en DB n'est PAS modifié — la contrainte est ré-évaluée à chaque scoring. **Frontend** : nouveau param `forced` sur `_lrc_star.blade.php` qui rend l'étoile en `<span>` non-cliquable (pas `<button>`) avec classes `is-essentiel is-forced`. Tooltip dédié : "Critère essentiel par défaut — un acheteur d'un type de bien spécifique ne reçoit jamais d'autres types. Ne peut pas être désactivé.". SCSS `.lrc-star.is-forced` : `cursor: not-allowed` + désactive le hover scale/shadow. **JS handler `click.lrcStar`** : early-return si `$star.hasClass('is-forced')` pour ignorer les clics éventuels (sécurité défense en profondeur, le `<span>` ne devrait pas matcher mais on est sûr). **Payload sync** : la collecte des étoiles essentielles pour POST `/criteria-levels` exclut désormais `.is-forced` via `.not('.is-forced')` — évite la double-source de vérité (backend = source de vérité pour `type`, criteria_levels DB pour le reste). **PIÈGES** : (1) `searchAdvertsForBuyer` n'appelait PAS `applyCriteriaLevels` avant ce sprint (juste `calculateAdvertMatchScore` brut) — ajouté maintenant pour cohérence avec `calculateMatchScore` ; (2) `$advert->match_excluded = !empty($result['excluded'])` exposé sur les annonces pour permettre au consommateur d'identifier les exclues si besoin ; (3) le check status accepte `'matched'` ET `'match'` (cf. bug fix du sprint critères essentiels) — donc `unavailable` = exclu en must (cohérent : type inconnu côté offre = pas de garantie qu'il match) ; (4) l'étoile forcée reste cliquable par accessibility tab car le `<span>` n'a pas `tabindex` — c'est OK, on veut juste empêcher le click.
- **Sprint Bon de visite ↔ création RDV intégrée (2026-05-27)** : la modal `#modalGenerateBonVisite` propose désormais une checkbox `.gbv-create-event-toggle` "Créer aussi un rendez-vous" qui révèle 4 champs (date / heure / durée 30-120min / notes) pour planifier la visite en même temps. Au submit : (1) bon de visite généré normalement, (2) si toggle coché, helper `window._gbvCreateEventWithConflictCheck` reproduit EXACTEMENT le pattern `agenda.js::_checkConflictAndSave` : `GET /agenda/check-conflict?start=&end=` puis si conflits → `simpleSwalWithoutAjax(...)` (boutons "Créer quand même" / "Modifier l'horaire"), sinon `POST /agenda/events`. **Pré-remplissage** : title = "Visite — {visiteur} ({adresse_bien})", description = notes optionnelles, event_type_id résolu via `window._gbvResolveVisiteTypeId()` (cherche `name_slug='visite'` ou `'visit'` dans `window.Agenda.eventTypes` préchargé à l'ouverture de la modal), lead_id = leadId, adresse_complete = `payload.property_address` (le bien visité), attendees = `[{type:'contact', id: contactId}]` (le visiteur sélectionné). **PIÈGES** : (1) Le `name_slug` peut être 'visite' OU 'visit' (gérer les 2) ; (2) si `Agenda.eventTypes` n'est pas chargé, on le pré-charge à l'ouverture de la modal via `$.get('/agenda/event-types')` ; (3) la création de l'event est ENCHAÎNÉE après celle du bon — si le bon échoue, on n'essaye même pas de créer l'event ; (4) si le bon réussit mais l'event échoue, on affiche un toast warning "Bon généré mais RDV non créé : {erreur}" et on continue (pas de rollback) ; (5) si conflit horaire et user refuse → "Bon généré — RDV non créé (à planifier)" + finish ; (6) le `attendees` array utilise le format `[{type:'contact', id: <int>}]` — pas `{type:'lead_contact', id: ...}` ni autre — vérifier avec backend `AgendaController::store` les types acceptés. **Vérifications backend non touchées** : `POST /agenda/events` accepte `attendees[].type='contact'` (cf. agenda.js:2640).
- **Sprint cleanup onglet Comm + bon_visite ↔ RDV liés (2026-05-27)** : refonte UX onglet "comm" pour éliminer les doublons et lier les bons de visite aux RDV agenda. **Cleanup** : (1) `_lead_quick_input.blade.php` — boutons `lead-input-type` pour `call`/`email`/`visit` retirés (doublons : appel = `.ld-open-log-call`, email = `.ld-open-send-message`, visite = `.leadEventReport` sur le RDV). Le bouton `note` reste exposé via hidden input pour rétro-compat JS (`acquisition.js:933` lit `.lead-input-type.active`). (2) `_lead_event_row.blade.php` — bouton "CR détaillé à saisir" (`.agenda-event-cr-pending`) supprimé. Plus qu'un seul flow de saisie de compte rendu via `.leadEventReport` (textarea simple). (3) `_modal_cr_visite.blade.php` — partial conservé sur disque mais `@include` retiré de `page_map_container.blade.php`. (4) `phase2-actions.js` — ~140 lignes du handler `modalCrVisite` supprimées (crvSaveDraft, openCrVisite, bindCardSelector × 2, click.p2crv, click.p2crvsub, onModalHidden). Routes `/visit/{id}/cr/draft` + `/visit/{id}/cr` + `/visit/{id}/send-cr` restent côté backend pour rétro-compat (aucune référence JS active). **Bon_visite ↔ RDV liés** : nouveau bloc `.lbv-visits-block` dans `_lead_bon_visite.blade.php` qui liste les `Agenda_event` de type visite (`event_type.name_slug IN ('visite','visit')`) du lead. Chaque RDV affiche : date (format jour/mois/heure), titre, badge contextuel (à venir / CR saisi / CR manquant / annulée), preview du report (si saisi), bouton `.leadEventReport` (variant warning si CR manquant, secondary si à modifier) + bouton `.editLeadEvent` pour modifier le RDV. Permet de saisir le compte rendu **directement depuis la section bon de visite** sans aller dans l'onglet Pilotage. SCSS dédié dans `_lead-redesign.scss` (`.lbv-visits-block`, `.lbv-visit-item`, `.lbv-visit-when`, `.lbv-visit-body`, `.lbv-visit-actions`) avec variant `is-needs-report` (background ambre) et `is-canceled` (opacity 0.55). **PIÈGES** : (1) le partial `_modal_cr_visite.blade.php` est conservé sur disque (deletion-safe au cas où) — il n'est plus `@include` mais reste accessible si on en a besoin ; (2) les routes `/visit/{id}/cr*` côté backend ne sont plus appelées par le frontend mais restent dans `web.php` ; (3) la relation `Agenda_event::event_type` est `belongsTo` (snake_case), pas `eventType` camelCase ; (4) le bouton `.leadEventReport` du nouveau bloc partage le même handler JS qu'ailleurs (modal `#leadEventReportModal` partagée — pas de duplication) ; (5) le bloc s'affiche uniquement si `$visitEvents->count() > 0` (sinon empty, pas de header orphelin).
- **Sprint critères essentiels — villes individuelles (2026-05-27)** : extension du pattern à la granularité ville par ville. Le dropdown `.lpc-zone-dropdown` (qui apparaît au hover du chip "Zones de recherche") expose maintenant une étoile par ville (`data-criteria-key="city:CODE"`). **Sémantique whitelist OR** : si AU MOINS UNE ville est marquée essentielle, le bien doit être dans l'une de ces villes essentielles (sinon exclusion). Si aucune n'est essentielle, comportement matching normal (location scoré au prorata du match exact/dépt). **La grosse étoile location globale a été supprimée** car redondante : marquer "Localisation" essentielle = exiger qu'au moins une zone match, ce qui est déjà le matching standard. **Backend `scoreLocation`** expose `demand_codes` (liste codes INSEE demandés) + `offer_code` (code INSEE du bien) pour qu'`applyCriteriaLevels` puisse filtrer la whitelist. **Backend `applyCriteriaLevels`** : pré-check sur toutes les clés `city:*` AVANT la boucle normale — collecte les codes `must`, si non vide alors `criteria.location.offer_code` doit être dedans (sinon exclusion avec reason `must:city_not_in_whitelist`). Les clés `city:*` sont ensuite skip dans la boucle pour éviter le double-traitement. **UI** : `.lpc-zone-dropdown-item` restructuré en flex avec name à gauche + étoile à droite (`margin-left: auto`), `lpc-zone-dropdown-item-name` ajouté pour le texte avec ellipsis. **4/4 tests backend OK** (city:CODE must match/no match, multi-villes whitelist OR, no city must = comportement normal). **PIÈGES** : (1) les codes city sont des strings (codes INSEE comme "66180") — toujours comparer via `(string)` cast ; (2) le pré-check city:* DOIT se faire AVANT la boucle générale et skip les clés city:* dedans (sinon double exclusion possible) ; (3) le `$cityEntries` collection en Blade filtre les villes sans code pour éviter les clés `city:` vides ; (4) `lpc-zone-dropdown` reste affiché via `:hover .lpc-zone-badge` — l'étoile fonctionne pendant le hover car elle est descendante du chip parent.
- **Sprint critères essentiels (2026-05-27)** : refonte UX du système criteria_levels (Must/Nice/Deal) du Phase 3 - A2. **Avant** : section dédiée `_lead_criteria_levels.blade.php` (table radio 4 colonnes Must/Nice/Deal sur 12 critères agrégés — encombrant, peu utilisé, ne permet PAS de marquer une feature individuelle essentielle). **Après** : un mini bouton étoile (`.lrc-star`) injecté discrètement dans chaque `.lead-req-chip` via partial `_lrc_star.blade.php`. Clic → toggle, POST `/acquisition/lead/{id}/criteria-levels` (endpoint conservé), backend stocke `criteria_levels[$key] = 'must'`. UX binaire (essentiel/normal) — wording **"Essentiel"** au lieu de Must/Nice/Deal. **Granularité étendue aux features individuelles** : chaque équipement (`hasPool`, `hasGarden`, `hasTerrace`, etc.) peut être marqué indépendamment essentiel — résout le cas user "piscine absolument requise sans rendre les autres équipements obligatoires". **Critères trackables** (data-criteria-key sur chaque chip) : `type`, `surface`, `surface_land`, `price`, `rooms`, `bedrooms`, `bathrooms`, `parking`, `dpe`, `ges`, `floor`, `standing`, `view`, `peaceful`, `location` + toutes les features `has*`. **Backend `HasLeadMatching::applyCriteriaLevels` refondu** : (1) **bug fix critique** : le check `$cri['status'] === 'match'` était cassé — les checkers retournent `'matched'` (avec `ed`), pas `'match'`. Donc l'ancien système ne fonctionnait probablement PAS du tout (tout must excluait à tort). Corrigé : `$status === 'matched' || $status === 'match'`. (2) Nouveau check pour les clés `has*` individuelles : compare `$criteria['features']['demand']`/`['offer']` (enrichis par `scoreFeatures` refondu pour exposer ces arrays). (3) `'nice'` et `'deal'` conservés en backend pour rétro-compat des leads existants (plus exposés en UI). **JS** : handler `click.lrcStar` dans `acquisition.js::initDetailTabs` (optimistic UI, recalcule tout `criteria_levels` depuis `$detail.find('.lrc-star.is-essentiel')` pour éviter races). **SCSS** : étoile discrète opacity 0.3 normal → opacity 1 + couleur dorée `#f0a13a` + text-shadow doré en mode `is-essentiel` ; la chip parent reçoit aussi `is-essentiel` avec liseré doré subtil `inset 0 0 0 1px rgba(240,161,58,0.45)`. Pour les chips colorées (DPE/GES fond foncé), override l'étoile en blanc/jaune clair. **5/5 tests unitaires passent** (rooms must/matched/unmatched, hasPool must avec/sans pool, no levels). Section `_lead_criteria_levels.blade.php` reste sur disque pour rétro-compat (plus `@include` nulle part). **PIÈGES** : (1) **le bug `'match'` vs `'matched'`** était silencieux jusqu'à présent — la logique Phase 3 - A2 ne marchait probablement pas. Toujours utiliser `'matched'` comme valeur de comparaison contre les statuts retournés par les checkers ; (2) `scoreFeatures` retourne désormais `demand`/`offer` arrays en plus du score agrégé — call-sites qui consomment ce retour peuvent les utiliser ; (3) côté UI, le partial `_lrc_star.blade.php` attend `$key` ET `$levels` — si `$levels` n'est pas calculé dans le partial parent, l'étoile ne s'allume jamais ; (4) le data-criteria-key DOIT être à la fois sur la chip parent (pour CSS .is-essentiel) ET sur le bouton étoile (pour récupération JS) — le partial l'écrit sur l'étoile, le call-site doit l'écrire sur la chip ; (5) `Lead.criteria_levels` est cast `array` (cf. fillable+casts) — l'endpoint POST `/criteria-levels` accepte le payload comme array PHP natif sans manipulation JSON.
- **Helper public `Acquisition._smoothScrollInDetail($target)` (2026-05-27)** : méthode réutilisable factorisée pour scroller smooth vers n'importe quel élément dans la fiche lead (`#acquisitionDetail`). Cible explicitement `$('#acquisitionDetailContent .scrollbar-container').first()` (= container PerfectScrollbar de la fiche lead) puis `.stop(true).animate({scrollTop}, 400)` avec centrage vertical (`relativeTop - clientHeight/2 + targetHeight/2`). Tente `Ps.update(container)` en callback de l'anim. Fallback `scrollIntoView` + `$('html, body').animate(...)` si pas dans une fiche lead. **4 call-sites refactorisés** vers ce helper unifié : (1) handler `click.ldJumpTab` (boutons `.ld-hero-jump-tab[data-scroll-to]` du Hero NBA / KPI strip) ; (2) handler `click.autoCtaScroll` (`.ldc-auto-cta[data-cta-scroll-selector]` des CTA contextuels du _lead_command_center) ; (3) méthode `_scrollToMatchCard` (scroll vers une match-card depuis la carte sur fiche lead) ; (4) `openProposalForm` (scroll après ouverture du form proposal dans l'onglet Comm). Tous utilisaient `scrollIntoView` natif qui scrollait le viewport au lieu du container interne — **bug silencieux jusqu'à présent** car les CTA bouton étaient probablement déjà près de la zone visible. **PIÈGES** : (1) toujours utiliser ce helper plutôt que `scrollIntoView` direct dans la fiche lead ; (2) le helper accepte un jQuery object OU un sélecteur DOM (cast auto via `if (typeof $target.length === 'undefined') $target = $($target)`) ; (3) `.stop(true)` annule les animations en cours sur le même container — évite les conflits si l'user reclique vite ; (4) `Ps.update` en callback est défensif (la lib écoute normalement les events scroll natifs de jQuery animate).
- **Sprint ld-hero-jump-tab data-scroll-to (2026-05-27, v3 stable)** : extension du handler `click.ldJumpTab` dans `acquisition.js::initDetailTabs` pour supporter un attr optionnel `data-scroll-to="<sélecteur CSS>"` sur les boutons `.ld-hero-jump-tab`. **Helper `smoothScrollToTarget($target)`** cible explicitement `$('#acquisitionDetailContent .scrollbar-container').first()` (= container scrollable principal de la fiche lead, PerfectScrollbar ArchitectUI), calcule `relativeTop = ($target.offset().top - $container.offset().top) + $container.scrollTop()`, centre via `relativeTop - clientHeight/2 + targetHeight/2`, clamp dans `[0, maxScroll]` puis `.stop(true).animate({scrollTop: newScrollTop}, 400)` jQuery (déclenche les events scroll natifs auxquels PerfectScrollbar s'accroche pour MAJ son track). `Ps.update(container)` en callback de l'anim. **2 tentatives précédentes échouées** : (v1) `scrollIntoView` natif → scroll le viewport au lieu du container interne, cible reste hors écran ; (v2) parcours `parentElement` avec `getComputedStyle.overflowY` + `container.scrollTo` → détectait un wrapper intermédiaire avec mini-gap (1-2px) qui s'auto-centrait, scroll de 1px parasite à chaque clic sans bouger visuellement. Le pattern v3 réplique EXACTEMENT ce qui marche déjà partout dans le codebase (`contact.js:706`, `filter.js:944`, `acquisition.js:196/259` qui sauvegardent/restaurent `acquisitionScrollPosition` sur ce même container). Highlight visuel : classe `.ld-flash` (1500ms) avec keyframe `ldFlashGlow` dans `_lead-redesign.scss`. Premier câblage : bouton KPI "Voir tous les contacts" (`_lead_kpi_strip.blade.php:378`) → `data-scroll-to=".ldc-quick-contacts"`. **PIÈGES** : (1) **TOUJOURS cibler `#acquisitionDetailContent .scrollbar-container` explicitement** pour la fiche lead — ne pas auto-détecter via getComputedStyle (échoue silencieusement) ; (2) `scrollIntoView` natif n'est PAS fiable avec PerfectScrollbar — utiliser `$container.animate({scrollTop})` jQuery qui déclenche bien les events scroll listened par PS ; (3) appeler `.stop(true)` avant `.animate()` pour annuler une anim en cours sur le même container (évite les conflits si l'user reclique vite) ; (4) `Ps.update()` en callback est défensif — la lib écoute normalement les events scroll natifs déclenchés par jQuery animate, mais un update explicite garantit la position du track ; (5) fallback `scrollIntoView` + `$('html,body').animate(...)` si on n'est PAS dans une fiche lead (cas hors `#acquisitionDetailContent`).
- **Sprint Hero NBA won — distinction terminal/intermédiaire + wording closed gradué (2026-05-27)** : `_lead_hero_nba.blade.php` gère 3 phases du `category='won'` avec des wordings différenciés. **Phase 1 — won non clôturé** : distinction via `$isTerminalWon` (= `acte_authentique_signed`/`bail_signed`) entre "Phase finale" (acte/bail signé, CTA "Clôturer la vente/location") et "Fin du pipeline" (won intermédiaire qualification_funnel, même CTA mais wording "Clôturer le suivi de vente/location"). **Phase 2 — won clôturé via closeSale** : NOUVEAU `$closureLevel` calcule le niveau d'aboutissement RÉEL en regardant les artefacts métier produits, NE PAS se fier à `acte_authentique_signed` qui n'est qu'un alias de `$lead->status === STATUS_SELLED` (toujours vrai après closeSale, donc trompeur). Hiérarchie : `compromis_signed` → 'sold'/'bought' (Bravo !), `offer_accepted` (BUYER) → 'offered', `mandat_signed` → 'mandated' (suivi clôturé), `avis_valeur_sent` → 'estimated' (suivi clôturé), aucun document signé → 'qualified' (suivi sans artefact). Pour LESSOR/TENANT : `bail_signed` → 'rented', `mandat_signed` → 'mandated', `avis_valeur_sent` → 'estimated', aucun → 'qualified'. 8 wordings dans `$closureWordings` (titre + sub + label + icon + class) — les 3 "Bravo !" (sold/bought/rented) gardent `lhn-success` (fa-trophy), 2 "Étape clé franchie" (committed/offered) utilisent `lhn-success-pending` (fa-handshake), 'mandated' = `lhn-success-pending` (fa-file-signature), 2 "Suivi clôturé" (estimated/qualified) = `lhn-terminal` (fa-flag-checkered) — wording neutre sans félicitations. **Décision design** : on ne propose PAS la "prochaine étape métier manquante" (mandat/compromis/etc.) sur les won intermédiaires — cela trahirait l'intention du funnel court ; les étapes restent accessibles via le pilotage. **PIÈGES** : (1) `acte_authentique_signed` du Lead_status_trigger est un proxy faible (= status_selled) — toujours utiliser `compromis_signed` (vraie signature Lead_proposal kind=compromis status signed) comme indicateur fiable d'une vente concluante. Le code stratégie a été corrigé suite à un faux positif "Dossier vendu — Bravo !" sur lead 624 qui n'avait que `avis_valeur_sent` ; (2) ordre de définition des variables Blade : `$isClosed` DOIT être calculé AVANT `$closureLevel` (qui dépend de lui) — si on déplace les blocs, vérifier l'ordre ; (3) `$closureLevel` n'est calculé que dans la branche `$isSelled && $isClosed` — null partout ailleurs.
- **Sprint Autocompletion qualification_funnel — statut "Qualifié" (2026-05-27)** : les 4 tâches typées du statut "Qualifié" du scénario `qualification_funnel` (config/pipeline_scenarios.php) — "Confirmer le projet de vente" (SELLER), "Confirmer le projet de location" (LESSOR), "Définir budget et zone de recherche" (BUYER), "Définir loyer max et critères" (TENANT) — passent de `auto_complete_on=null` → `auto_complete_on='lead_requirement_filled'`. Le nouvel event est défini dans `core-city-user/src/Models/Lead_status_trigger.php` (EVENT_CHECKS + EVENT_LABELS + EVENT_GROUPS['lifecycle']) avec un checker `checkLeadRequirementFilled` qui valide qu'un critère métier "vrai" est saisi dans `requirement_data` (budget/surface/zone — JAMAIS le batiType, pour éviter la collision avec `lead_qualified`). Fire déclenché dans `AcquisitionController::updateRequirement` après save (HORS du `if($typeChanged)`) AVEC un pre-check `Lead_status_trigger::checkSingleCondition()` — sinon le `fire()` cocherait toutes les tasks `auto_complete_on=lead_requirement_filled` sans condition (le `fire()` n'utilise les checkers QUE pour les transitions de statut, pas pour l'auto-completion de tâches). Hero NBA (`_lead_hero_nba.blade.php`) : nouvelle entrée `$nbaActionMap['lead_requirement_filled']` (label "Confirmer le projet", icon `fa-sliders-h`, class `lhn-trigger-requirement-edit` — déjà câblée dans `acquisition.js` ligne 2555 vers `toggleRequirementEdit()` → `openEditLeadModal(leadId, prefill)` qui skip Step 1 si `requirement_type` défini et bascule directement sur `#leadStep2.lead-step--active`). Override de label contextualisé par `requirement_type` (Confirmer le projet de vente/location ou Définir budget&zone/loyer&critères). **PAS de migration data** : les pipelines déjà déployés en BDD doivent être ré-appliqués via le wizard `#modalPipelineWizard` → `POST /parametres/pipeline/apply-preset` → `PipelinePresetService::applyScenario`. **PIÈGES** : (1) `Lead_status_trigger::fire()` ne ré-évalue PAS les checkers lors de l'auto-completion de tâches — pour des events conditionnels comme `lead_requirement_filled`, TOUJOURS faire un pre-check `checkSingleCondition()` côté call-site avant de fire ; (2) NE PAS inclure `*BatiType` dans la liste des critères acceptés par `checkLeadRequirementFilled` (collision avec `lead_qualified` si l'agent ne fait que renseigner le type au Step 1 avec batiType pré-rempli depuis une parcelle) ; (3) la classe CSS `lhn-trigger-requirement-edit` est déjà câblée dans `acquisition.js` — pas de nouveau handler JS nécessaire ; (4) le scope du périmètre Hoguet n'est pas concerné — le qualification_funnel est un scénario indépendant qui s'applique à tous les modes pro (`independant`, `agence_societe`, `agence_individuelle`, `mandataire_reseau`).
- **Sprint Modales AJAX-on-open (2026-05-27)** — refactor `modalGenerateBonVisite` + `modalGenerateBail` pour éliminer le bug de fraîcheur des contacts/biens épinglés après ajout dynamique (match manuel via `modalAddMatchManual`, contact via `modalAddLeadContact`). Aligne ces 2 modales sur le pattern AJAX-on-open déjà utilisé par 6 autres (mandat, compromis, lettre prospection, demande pieces, publish advert, send message via `loadLeadContacts`). **Backend** : nouveau endpoint `GET /acquisition/lead/{id}/visit-properties` → `AcquisitionController::getVisitProperties` (renvoie biens épinglés Lead_proposed_match en JSON `{kind, ref, address, meta, match_id, matched_lead_id|advert_flx_id}`). `getContacts` enrichi avec `role_label` (FR via `Lead_contact::ROLE_LABELS`) sur chaque ligne — non-breaking. **Frontend `phase2-actions.js`** : `loadLeadContacts` étendu avec 4 opts non-breaking (`placeholder`, `afterLoad`, `showRoleLabel`, `preselectRole`) ; nouveaux helpers `loadLeadVisitProperties(leadId, $list, opts)` et `loadLeadContactsMulti(leadId, configs)` (UN SEUL GET partagé pour N selects — utilisé par bail pour 4 selects). **Vues** : `_lead_bon_visite.blade.php` perd son `@php` lourd (50 lignes — query `Lead_proposed_match` + `$availableContactsData`/`$availableProperties`) — le bouton `.lbv-generate-btn` ne porte plus que `data-lead-id`. `_modal_generate_bail.blade.php` perd son `@php Lead_contact::where()` ; les 4 selects ont des IDs explicites `gblLessorSelect`/`gblTenantSelect`/`gblCoTenantSelect`/`gblGuarantorSelect` et sont peuplés par `loadLeadContactsMulti` au clic sur `.generate-bail-trigger` ; `data-url` du form devenu dynamique. **Handlers** : `client-portal-admin.js` (`click.lbvGen`) appelle `loadLeadContacts` + `loadLeadVisitProperties` avec callbacks gérant les empty states `.gbv-empty-contacts`/`.gbv-property-empty` et l'activation submit ; conserve `fixBackdrop($modal)` local après `showModal`. Bail (`click.bailTrigger`) appelle `loadLeadContactsMulti` avec `preselectRole: 'guarantor'` sur le 4e config. **Garanties par construction** : chaque ouverture émet un GET frais → impossible d'avoir un cache obsolète après mutation. Validé par test BDD (insertion `lead_proposed_match` puis ré-ouverture → nouveau bien visible sans reload, 2/2 cards rendues). 0 régression sur les 6 modales existantes (opts opt-in, fallback identique). **PIÈGES** : (1) `lead_proposed_matches` n'a PAS de colonne `updated_at` ; (2) `Lead_contact::ROLE_GUARANTOR = 'guarantor'` (PAS `Lead_contact::ROLES` qui n'existe pas) ; (3) délégation jQuery `$doc.off().on()` obligatoire car bouton trigger dans la fiche AJAX-injectée ; (4) `_modal_generate_bail.blade.php` est inclus depuis `lead_detail.blade.php:202` (rendu à chaque `showDetail`) — `$leadId` reste défini ; (5) endpoint `getVisitProperties` filtre sur `Lead::where('user_id', auth)` (cohérent avec `getContacts`/`leadMatches`).
- **Sprint Portail UX (2026-05-27)** — passage du portail client à 🟢 partout (style cohérent dark/compact, code mort supprimé). **Phase A** : suppression `_milestones_section.blade.php` (doublon parfait de `_timeline.blade.php` — même donnée `$context['pipeline']`, même titre "Votre parcours") et de ses 4 `@include` dans `seller/buyer/lessor/tenant.blade.php`. Permission `milestones` du `Client_portal_token::PROFILE_PERMISSIONS` CONSERVÉE (low-risk). **Phase B** : refonte SCSS de 4 partials qui étaient 100% styles inline (cassait dark/compact) — `_documents.blade.php` (section "À signer" + modal `#cpSignModal`), `_self_booking_section.blade.php` (retiré Bootstrap 4), `_push_optin.blade.php`. Nouvelles CSS vars `--cp-warn`/`--cp-warn-rgb`/`--cp-warn-soft`/`--cp-warn-strong`/`--cp-warn-ink` (orange ambre, variant dark `#fbbf24`) + `--cp-success`/`--cp-danger` semantic. **Phase C** : migration SCSS de `_estimation_section.blade.php` (~70 lignes inline) et `_photos_section.blade.php` (~30 lignes inline, lightbox CSS-only `:target` conservée). **Phase E** : enrichissement `_key_data.blade.php` cards matches — carrousel CSS-only `.cp-match-carousel` (scroll-snap-x mandatory + dots overlay glassmorphism, jusqu'à 5 photos par match), badges contextuels `.cp-match-badge-new` (vert) / `.cp-match-badge-exclusive` (accent), chips équipements `.cp-match-extra-chip` (DPE coloré officiel A-G + RDC/étage, année, terrasse, jardin, piscine, parking, garage, cave, ascenseur, cheminée), prix/m² calculé `.cp-kpi-price-sub` sous le prix principal. Côté core-city-user : nouveaux helpers privés `portalLoadAdvertImagesMulti`, `portalLoadLeadPhotosMulti`, `portalExtractAdvertExtras`, `portalFormatFloorLabel` dans `HasClientPortal` ; payload `buildKeyData` enrichi avec `thumbs[]`, `bedrooms`, `dpe` (filtré strict A-G), `price_per_m2`, `construction_year`, `floor_label`, `extras[]`, `is_exclusive`, `is_new` (<14j). SCSS portail passe de ~2790 à 3673 lignes (`public/css/client_portal.css` 62 KiB). **PIÈGES** : (1) JS `cpSignModal` migré : `.cp-sign-close` → `.cp-sign-modal-close`, `.ecad-sig-*` → `.cp-sig-*`, toggle via `classList.add('is-open')` au lieu de `style.display`. (2) DPE adverts contient parfois `ENERGY_CLASSIFICATION_UNKNOWN` — filtré strict via `preg_match('/^([A-G])$/i')` avant exposition. (3) Year construct côté lead matched stocké en range "1975-1984" dans `estimation.criteria.year_construct` — extraction `^\d{4}` pour ne garder que la borne inf. (4) Carrousel dots utilise `<a href="#cpc-{ref}-{idx}">` qui jump-scroll dans `.cp-match-carousel-track` — pas de JS, scroll-snap-type:x mandatory fait le travail.
- **Finition CRM (2026-05-26) — A4/A9/E1** : (1) **A4** `Lead::matching_score_meta` accessor mémoïsé (core-city-user/Lead.php, pattern mandate_block_reason) — BUYER/TENANT only, retourne `{best_score, total_matches, excellent_count>=80}` ou null, appelle `HasLeadMatching::findMatchingProperties` via classe anonyme `use HasLeadMatching`, concatène scores `leads`+`adverts`. À n'utiliser que sur fiche détail (1 query/lead), JAMAIS sur kanban (N+1). `_lead_kpi_strip.blade.php:104-111` simplifié : rendu adaptatif « Meilleur match : N% / N excellents » vs « Aucun bien correspondant » (supprime l'approximation `min(100, matchesCount*10)` + TODO obsolète). (2) **A9** `_lead_hero_nba.blade.php:179-189` — quand NBA `portal_link_sent` sans `$activeToken`, bascule vers `nbaActionMap['portal_token_created']` au lieu de retomber sur « Marquer fait » (évite que l'agent coche silencieusement la tâche sans avoir créé le portail). (3) **E1** `compte/mandats/index.blade.php` — 3 filtres client-side (Type/Statut/Recherche) ajoutés à la toolbar existante + `data-row-kind/status/search` sur chaque tr + IIFE `applyMandateFilters()` avec empty state réutilisant `.mandats-verify-result.loading` + lien « Réinitialiser ». Filtres masqués si registre vide (`@unless($entries->isEmpty())`). 100% client-side, l'export PDF coté/paraphé Hoguet reste basé sur la query serveur. Aucune nouvelle classe CSS — réutilise `.mandats-toolbar-label` + `.form-control form-control-sm` + design tokens CRM existants.
- **Modale "Parcelles du propriétaire" — onglet Ayants-droit fiche parcelle (2026-06-02)** : dans `#tab-owner` (#colCard), le bloc compteur "N Parcelles" d'une personne morale (`card_owner_item.blade.php`) et un nouveau déclencheur "Voir les parcelles" pour les copropriétés (`card_owner_co_item.blade.php`) deviennent cliquables (`.owner-parcels-trigger` avec `data-owner-type=moral|coowner`, `data-owner-key`=SIREN/immatriculation, `data-owner-label`). Le clic ouvre **`#modalOwnerParcels`** (`parcel/modal/modal_owner_parcels.blade.php`, push dans `page_map_container`, gabarit `modalParcelNote` + table `.addmatch-modal`) **empilée par-dessus #colCard** (z-index 3000), avec tableau filtrable/triable (Réf. cadastrale / Commune / Superficie / Type bâti·non bâti) + recherche live + filtres commune (auto-masqué si 1 seule)/type. **Endpoint** `POST /parcel/owner-parcels` → `TabOwnerController@ownerParcels` (moral via `Fr_moral_person_siren`→`person_morals`→`map`→`Fr_parcel` ; coowner via `Fr_co_owner`→`Fr_co_owner_map`→`Fr_parcel`) renvoie `{owner, count, truncated, parcels[]}` (cap 500, eager-load `insee:id,code_insee,nom_commune`+`detail_light`, type via `count_building_heavy>0`). **JS** `parcel-tabs/tab-owner.js` (était vide, importé par `tab-init.js`) : handlers délégués sur `document` (contenu AJAX), tri client + recherche. **2 actions par ligne** : (1) **Voir la parcelle** `op-action-view` → `hideModal('#modalOwnerParcels')`+`closeParcel()` puis `setExternalParcelSelection([cad])` + `loadLayer(map,'parcel',cad, cb)` → `fitBounds(map,{feature})` (zoome + sélectionne, widget `#map-top-right-container`) ; (2) **Activer la couche & filtrer** `op-action-layer` → `window.applyOverlayerFilterProgrammatic('morales'|'copros', 'morales_siren'|'copro_immatriculation', [{id,label}])` (core-city) : active la couche overlay du propriétaire filtrée par SIREN/immatriculation (parcelles colorées sur la carte). SCSS `_owner-parcels-modal.scss` (réutilise tokens `.addmatch-*`). **Vérifié Chrome DevTools** : modale empilée OK (25 lignes EURL FONCIER 66), Action 1 zoome+sélectionne (zoom 16, badge "1"), Action 2 backend 25 features filtrées vs 1859 non filtrées, 0 erreur console. **PIÈGES** : (1) une parcelle peut avoir PLUSIEURS propriétaires moraux → le compteur card = `person_morals->count()` (localités, approximatif) tandis que la modale liste les vraies parcelles distinctes ; (2) `applyOverlayerFilterProgrammatic` est dans core-city (bundle `app-core-city.js`) → **rebuild core-city + recopier le bundle** vers `public/core-city/js/`; (3) bundle core-city à URL non versionnée → hard-reload navigateur requis en test.
  - **Corrections 2026-06-02 (retour utilisateur)** : (a) **Dédup par SIREN/immatriculation** dans `TabOwnerController@load` (avant : dédup par `moral_person->id` → des centaines de cartes doublons pour un même SIREN ; ex parcelle `66136000BH0528` = 855 lignes MAJIC pour 1 SIREN → désormais 1 carte). (b) **Backdrop modale empilée** : `#colCard` ET `#modalOwnerParcels` ont le MÊME z-index (3000, `.modal-side-right`) → le pattern `zIndex-1` de modalParcelNote donne 2999 (DERRIÈRE colCard). Fix robuste dans `onModalOpen('#modalOwnerParcels')` : `$modal.css('z-index', baseZ+20)` (3020) + `.modal-backdrop:last` à `baseZ+10` (3010) → backdrop DEVANT colCard, modale DEVANT backdrop. (c) **Bouton couche unique** : l'action « Afficher la couche » (commune à toutes les parcelles du propriétaire) est sortie des lignes (`op-action-layer` supprimé) vers un bouton `#ownerParcelsLayerBtn` dans `.owner-parcels-filter-grid` (3ᵉ colonne) ; chaque ligne ne garde que `op-action-view` (œil). (d) **Action 1 « Voir la parcelle » fiable depuis tout état de carte** : le backend renvoie la **bbox** (`ST_Envelope(geo_shape)` → `{w,s,e,n}`) par parcelle ; JS fait `map.fitBounds(bounds)` (clamp zoom ≤19) + `setExternalParcelSelection` — le fitBounds déclenche le rechargement bbox du layer parcelle → hook `afterLoad` la colore. **NE PAS** utiliser `loadLayer(map,'parcel',code)` : la route `GET /city/parcel/{code}` **n'existe pas (404)** et est inutile (le fitBounds suffit à dessiner+colorer). Validé Chrome : depuis zoom 6 (Paris) → Action 1 navigue à la parcelle zoom 19 centrée + colorée + widget « 1 parcelle », 0 erreur console. (e) **Compteur onglet `Ayants-droit (N)`** (`card.blade.php` ~l.24-41) réaligné sur la dédup de `TabOwnerController@load` : `$moralPersonsCount` unique par **SIREN** (fallback `mp:id`), `$coOwnersCount` unique par **immatriculation** (AVANT : dédupait par SIREN du **syndic** + EXCLUAIT les copropriétés sans syndic → grosse sous-évaluation), `$pEstablisementsCount` unique par **id** (AVANT : `siret`). Eager-load `moral_person_map.moral_person.person_moral_siren` ajouté dans `TabInfoController` (évite N+1). Validé Chrome sur `66136000EP0157` : badge **36** = **36 `.list-group-item`** rendus (1 moral + 21 copros dont 16 sans syndic + 14 établissements) — l'ancien code affichait ~20.
- 9 packages tiers configures (captcha, dompdf, invoices, passwordless, share, blocker, markdown, scout, favicon)
- **Le mode personnel d'un membre d'agence suit désormais son rôle (2026-09-18)** : signalé - « j'ai rejoint une agence en restant en profil prestataire de services ; je devrais avoir le mode Agent commercial selon mon rôle, et je ne peux plus changer : aucun mode ne dit que j'ai rejoint une agence ». **Constat** : `acceptJoin` laissait volontairement la fiche perso (`core.user_legal_notices`, scope `user`) sur le mode choisi AVANT (note de 2026-05-20 : « pas de blocage côté backend ») ; le bandeau profil affichait « Mode personnel : Prestataire de services » sous « Mon agence · Agent commercial », et `#agencyChooseModeModal` n'avait que le panneau OWNER_LOCK - un simple membre retombait sur le sélecteur des 6 modes perso, aucun n'exprimant l'appartenance. Les fonctions métier, elles, résolvaient déjà l'agence en premier (`HasAgency::resolveLegalNoticeStatus`, `PipelinePresetService::resolveProMode`, `isHoguetCompliant`, `leadCapabilityTier`) : le décalage était d'affichage et de pilotage, pas de droits. **Livré (site seul, 0 migration, 0 route, 0 fichier de core)** : `AgencyController::ROLE_TO_PERSONAL_STATUS` (agent_commercial / mandataire / member → `agent_commercial` ; agent_salarie / manager / viewer → `agent_salarie` ; owner → inchangé) + `alignPersonalStatusToRole()` appelé à l'adhésion (lien générique ET invitation nominative) et au changement de rôle (`updateRole`) ; le mode précédent (statut + `activity_category`) est mémorisé une seule fois dans `network_metadata.before_agency` et **restitué** par `restorePersonalStatusAfterLeaving()` à `leave`, `removeMember` et `archive` (sans souvenir : statut remis à null = « à configurer » ; membre d'une autre agence : réaligné sur ce rôle-là) ; **garde propriétaire** (un owner d'agence n'est jamais réaligné par un rôle tenu ailleurs) ; `checkModeTransition` répond **423 `MEMBER_LOCK`** sur les 4 modes perso tant qu'on est membre (« créer » / « rejoindre » restent ouverts) ; modale : panneau « Vous faites partie d'une agence » (mode dérivé, agence + rôle, marche à suivre, lien « rejoindre une autre agence » → `#agencyJoinTokenModal`) ; bandeau profil : « Mode : Agent commercial mandaté · déterminé par votre rôle », CTA « Mon mode ». ⚠ **Rattrapage de l'existant** : `.insights/align-agency-member-status-2026-09-18.php` (`--apply`, idempotent) - à rejouer en prod après déploiement ; ⚠ le script résout le projet comme `resolveProjectId()` (`Project_site::find(APP_ID)->project_id`), **pas** `env('APP_ID')` qui est l'id du SITE. **🟥 Piège JS trouvé en recette** : les pages `/compte/*` chargent `public/js/setting-agency-standalone.js` (**mix.copy** de `resources/js/setting-agency.js`, `webpack.mix.js` l. 201) AVANT `app.js`, et son garde `_settingAgencyInitDone` fait taire la copie bundlée - un `npm run dev` interrompu (ici par `public/documents` appartenant à root) laisse la standalone périmée : le nouveau handler existait dans `app.js` mais ne tournait jamais. **Toujours vérifier que la standalone est régénérée.** 2e piège : `modal('hide')` depuis ce bundle est un no-op sur l'instance créée par l'autre jQuery → fermeture par `window.resetModal` + purge backdrop (recette de `pipeWizCloseThenOpen`). Vérifié : harness `.insights/harness-agency-member-status-2026-09-18.php` **16/16** sur les vraies méthodes (adhésion lien → agent_commercial + souvenir, 423 MEMBER_LOCK / 200 créer, changement de rôle → agent_salarie souvenir intact, idempotence, garde owner, départ → prestataire + activité restaurés, retrait par manager idem) ; recette navigateur en session réelle d'un membre (bandeau, modale, lien vers la modale d'invitation, POST /parametres/mode/check → 423) ; `php artisan test` 157 / 3 échecs préexistants (ExampleTest + 2 Pipeline*RequirementType*) ; base rendue à l'identique. ⚠ **Environnement** : `public/js`, `public/css`, `public/core` et `mix-manifest.json` appartenaient à l'uid 1002 (build prod du 18/09 00:02) → remplacés par des copies julio (`*.old1002` conservés, à supprimer avec sudo) ; le build est désormais **dev** (manifest sans hash). Déploiement : `git pull` + `npm run prod` (régénère aussi la standalone) + `view:clear`, puis le script de rattrapage.
- **Pipeline unifié `/parametres#taches` (Refonte 2026-05-19)** — fusion des 3 anciennes sections (Pipeline + Transitions automatiques + Tâches automatiques) en une **seule vue centrée sur les statuts**. Chaque carte statut s'ouvre pour révéler 3 sous-sections : (1) Apparence (couleur + icône **inline**, plus de popover), (2) À l'entrée — tâches créées auto, (3) Sortie automatique — transitions sortantes. Footer "Arrive depuis" pour les transitions entrantes (lecture seule). Nouveau **wizard preset 2 steps** `#modalPipelineWizard` : mode pro dérivé serveur (`PipelinePresetService::resolveProMode` priorise `User::currentAgency()` puis `User_legal_notice.status`), pas accepté du client. Step 1 = types de mandats (vente/location), Step 2 = niveau d'automatisation (minimal/standard/maximal). Si mode pro non configuré → CTA ouvre `#agencyChooseModeModal` existante, retour automatique via `hidden.bs.modal` + sessionStorage `pipe_wizard_pending`. Endpoint `POST /parametres/pipeline/apply-preset` (entity-aware via `resolveSettingScope`) ; archive l'existant + crée statuts/tâches/triggers + remap leads via `Lead_status::remapLeadsForUser`. Matrice presets dans `config/pipeline_presets.php` indexée par `mode_pro × mandate_key × niveau`. Fichiers : `App\Services\PipelinePresetService`, `setting/automatisation/_pipeline_status_card.blade.php`, `setting/automatisation/_preset_wizard.blade.php`, `resources/js/setting-pipeline.js` (bundlé dans `app.js`), `resources/sass/_setting-pipeline.scss` (étend `.st-doc-section-card` + tokens `--crm-*` + anim `crmFadeInUp` + `.crm-empty-compact`). Classes `.st-pipe-*` créées, **classes partagées `.st-at-section/header/icon/switch/form-*/badge/empty/trigger (sans variantes)/add-btn` préservées** (utilisées par sections 4-8 Notifications/Digest/Relances/Portail/PWA/Vacances). JS inline obsolète (~1200 lignes) désactivé via `@if(false)` dans la vue, à supprimer après stabilisation. `SettingController::list()` expose `$triggersByFromStatus`, `$triggersByToStatus`, `$pipelineIsDefault`, `$resolvedProMode`, `$proModeLabel`, `$hasProModeConfigured`. **`window.initSettingPipeline()`** appelée depuis `menu-map.js::loadPostContentMenu` callback (après `initSettingTabs`). PIÈGES : (1) un seul sélecteur jQuery `.st-pipe-card-head` pour le toggle (pas dual avec `.st-pipe-toggle` sinon double-toggle car le bubble matche les 2) ; (2) `Lead_status::generateSlug($ownerId, $name)` obligatoire pour les statuts scope `group` (unique constraint `(user_id, slug)`) ; (3) mode pro NE JAMAIS accepter du client (sinon registre Hoguet contournable) ; (4) la route `POST /parametres/pipeline/apply-preset` est dans le groupe `/parametres` avant `/parametres/{map?}` ; (5) Bootstrap `$.fn.modal` peut sembler undefined dans la console mais `window.showModal('#id')`/`window.hideModal('#id')` du core fonctionnent ; (6) le statut `agence_individuelle` retombe sur `agence_societe` dans la matrice presets via fallback chain `resolvePreset()`.
- **Widgets — Modes de distribution (2026-05-23)** — Sprint qui élargit le champ fonctionnel : 3 modes mutuellement exclusifs par widget remplacent le simple toggle `is_active`. Migration `core/database/migrations/2026_05_23_000001_add_hosting_to_user_widgets.php` ajoute sur `core.user_widgets` : `hosting_mode VARCHAR(16) DEFAULT 'embed'` (`disabled|hosted|embed`), `hosted_slug VARCHAR(80) UNIQUE NULLABLE` (URL `/w/{slug}`), `embed_url VARCHAR(500) NULLABLE`, `landing_meta JSONB DEFAULT '{}'`. Backfill : `'embed'` si is_active, sinon `'disabled'`. **`User_widget::generateHostedSlug($widget)`** format `{brand}-{token8}` : brand dérivé de `name → agency_name → typeSlug(type)` slugifié (max 40 chars), token cryptographique 8 chars `[a-z0-9]` (36^8 ≈ 2,8 × 10¹² combinaisons → impossible à énumérer). Slug **immutable** après génération (hook `saving` : conservé même si bascule vers autre mode pour ne pas casser les QR imprimés). **3 panels conditionnels** dans la fiche config (`widget/_distribution_section.blade.php`) : Désactivé (note explicative) / Page partageable (URL `/w/{slug}` + boutons copier/ouvrir/partager + QR SVG inline + boutons download SVG/PNG + teaser flyer IA) / Mon site web (snippet `<script>` + champ `embed_url` optionnel + QR sur cette URL). État A/B dans panel hosted : "Cliquez sur Sauvegarder" si pas encore de slug, URL+QR rendus une fois slug créé. **Routes nouvelles** : `GET /w/{slug}` (publique, throttle 60/min, regex `[a-z0-9][a-z0-9-]{5,79}`, 404 silencieux → `WidgetController::hostedPage` qui rend `widget/hosted_page.blade.php` standalone brandé agence avec OG meta + footer "Propulsé par InfoParcelle") et `GET /widgets/{uuid}/qr/{format}` (auth, format=svg\|png, `?inline=1` sans attachment, ownership check via user_id → `WidgetController::qrCode` utilisant `chillerlan\QRCode` ECC_M scale=12/8). **Save** : `WidgetController::update()` validation étendue (`hosting_mode in:disabled,hosted,embed`, `embed_url nullable|url|max:500`), `is_active` suit `hosting_mode` (false si disabled), réponse JSON enrichie avec `hosted_slug`/`public_url`/`slug_just_created` (déclenche reload `loadPostContentMenu` côté JS pour afficher URL+QR fraîchement créés). **3 forms** (`_form_estimation/dpe/opportunite`) modifiés : suppression du bouton "Intégrer" + panel `wt-integration-panel` (devenu redondant) + inclusion `_distribution_section` après `_form_widget_identity` + extension IIFE JS (`applyHostingMode(mode)` toggle panels + `is-selected` cards + grise preview, `btn-share-hosted` Web Share API avec fallback copy, copy feedback unifié `#panel-embed/#panel-hosted .copy-to-clipboard`, payload save étendu avec `hosting_mode`/`embed_url`). **Badge mode dans card liste** (`_card.blade.php`) : `wl-badge-mode--hosted/embed/disabled` (vert/bleu/gris). **SCSS** : nouveau `_widget-distribution.scss` (~280 lignes : grid 3 cols responsive, `wt-dist-card` avec `is-selected` border primary, `wt-panel`, `wt-share-url` flex copy, `wt-qr-box` centré ombré, `wt-qr-empty` dashed, `wt-ai-flyer-teaser` gradient ambre/rose, `wt-preview--disabled` overlay, `wl-badge-mode--*`). **Vision long-terme** : architecture compatible avec le futur "mini-site agence clé en main" (annonces + recherche + widgets injectables sans copier-coller — le `User_widget.uuid` reste l'unité de configuration, le futur mini-site listera les widgets actifs du user et les rendra inline). **Placeholder UI "🪄 Générer un flyer avec IA"** dans les panels hosted/embed (non cliquable, badge "Bientôt") prépare la prochaine itération crédits IA.
- **Sprint NPS + Avis clients (2026-05-22)** — Système d'avis client complet : NPS post-vente J+7 + saisie manuelle + plateformes externes. **Backend** : nouveau modèle `Lead_review` (core-city-user, table `lead_reviews` connexion `pgsql_city_user`) avec multi-agence v5 (`group_id`+`scope`). Services `ReviewStatsService` (agrégation stats normalisées sur 10, top reviews ordonné pinned > manual > nps) + `BrandingResolver` (cascade perso > agence > défaut couleurs/logo/agent_name). **Controllers** : `Lead_reviewController` (7 routes CRUD `/reviews/*` avec `resolveReviewScope` entity-aware + `authorizeReviewRow` par ligne — pattern `SettingController::resolveSettingScope`). `NpsController` refactor complet : `recordScore` (legacy email score links) redirige vers `showForm` (formulaire NPS complet style portail client, signed URL 30j) qui submit vers `submitForm` (crée/maj Lead_review + Lead_activity nps_{detractor|passive|promoter}). `thankYou` lit `nps_review_url_google`/`nps_review_url_pro`/`nps_review_url_pro_label` (cascade hasSetting). `storeFeedback` requiert signature signed (was non-signed avant). **AcquisitionController::closeSale** : `nps_optout` flag positionné sur `lead.metadata` si `schedule_nps` non coché. **NpsAfterSaleCommand** modifié : filtre `nps_enabled` user (cascade hasSetting, défaut true) + skip `Lead.metadata.nps_optout` + variables template `nps.form_url` + `nps.suggested_title` (titre marketing "Vente Maison 6P à 75001 Paris" depuis lead/estimation). **UI Admin** : nouvel onglet "Avis clients" (`#st-panel-avis`) dans `/parametres` avec section stats (`%` satisfaction + étoiles 0-5 + indices détaillés 5 critères barres de progression) + liste reviews triée (drag handle, pin badge, source badge, edit/delete/toggle actions). Modal `_modal_add_review` avec sliders 0-10 par critère (rating + 5 indices détaillés). Section "Avis clients post-vente (NPS)" dans `preferences.blade.php` (toggle `nps_enabled` + URLs Google/pro + label pro + `pdf_avis_show_reviews` auto/always/never). **UI Public** : nouvelle vue `nps/form.blade.php` autonome (HTML+CSS inline, branding agence via CSS vars `--np-primary/secondary/text`, sliders main+détaillés, autocomplete identité contact, redirige thank-you). Refactor `nps/promoter.blade.php` pour URLs Google/pro paramétrables (fallback message si vide). `nps/passive`/`detractor` utilisent `$feedbackSignedUrl` (route signée). **PDF Avis de Valeur** : nouvelle page conditionnelle `page_avis_client.blade.php` (core-city, design 1-page : stats globales + indices détaillés barres + 6 reviews cards max, DomPDF-friendly inline CSS avec couleurs Blade-injected). `PdfParcelsAvis::page_avis_client()` méthode statique appelée après `page_presentation_conseiller`, respect setting `pdf_avis_show_reviews` (auto≥3 / always≥1 / never). **Routes** : `/nps/{lead}/form` + `/nps/{lead}/submit` (signed publiques) + `/reviews/*` (7 routes auth). **Migrations** : `2026_05_22_000001_create_lead_reviews_table.php` + `2026_05_22_000002_backfill_nps_scores_to_reviews.php` (transforme `Lead.metadata.nps_score` legacy ; **PIÈGE : utiliser `jsonb_exists()` PAS `?` car PDO bindings**). **JS** : `setting-reviews.js` (CRUD AJAX, modal handlers, sliders update, swal confirm delete). **SCSS** : `_setting-reviews.scss` (~200 lignes). **PIÈGES** : (1) Routes NPS signed : `URL::hasValidSignature()` requis dans chaque méthode (pas de middleware `signed` global) ; (2) Form NPS doit déclarer `nps.form` AVANT `nps.score` car le regex `[0-9]+|10` pour score n'avale pas "form" mais c'est plus safe ; (3) Branding `BrandingResolver` cascade automatique via `hasSetting()` ; (4) `Lead_review::getEffectiveFor($user)` retourne perso ∪ agence active (auto-seed N/A — table reste vide jusqu'aux 1ers avis) ; (5) Onglet "avis" ajouté à `visibleTabs` pour TOUS les modes (particulier inclus) ; (6) `Lead_reviewController::resolveReviewScope` route automatiquement vers agence si user manager — explicite `entity=user|group:N` pour forcer.
- **Widgets multi-instance (2026-05-22)** — Refonte de "1 widget par (user, type)" à "N widgets de chaque type par user". Migration `core/database/migrations/2026_05_22_000001_add_uuid_group_id_to_user_widgets.php` ajoute sur `core.user_widgets` : `uuid VARCHAR(36) UNIQUE` (identifiant public utilisé dans URL embed `/widget/{uuid}.js` et `?uuid=` API config), `name VARCHAR(120) NULL` (nom libre, fallback `User_widget::displayName()`), `group_id BIGINT NULL` (agence destinataire des leads), `scope VARCHAR(10) DEFAULT 'user'`. **Drop unique `(user_id, type)` → index simple**. `User_widget::booted::creating` auto-génère uuid + auto-assigne `group_id` si user a exactement 1 agence active (silencieux). Relation `agency()` ajoutée. Méthodes `User_widget::typeLabel($type)`, `displayName()` (renvoie `name` ou `"{Type} #N"` calculé par rang). **`User::widgetConfig($type, ?$uuid = null)`** : si uuid fourni → lookup direct ; sinon → 1er widget actif du type (legacy). **Règles UI agence** : si user `allAgenciesActive()->count() === 0` → pas de sélecteur, `group_id` reste NULL (leads perso) ; si `=== 1` → pas de sélecteur, auto-assigné par boot ; si `>= 2` → sélecteur visible dans `_form_widget_identity.blade.php` + modale create, défaut = `currentAgency()` (sidebar switcher). **Routes refondues** : `POST /widgets/create/{type}` (create), `POST/PUT /widgets/{uuid}` (config/update), `POST /widgets/{uuid}/toggle-active`, `DELETE /widgets/{uuid}`. Legacy `POST/PUT /widgets/{estimation|dpe|opportunite}` + `POST /widgets/toggle-active` préservées (rétro-compat, ciblent le 1er widget actif du type). Regex uuid stricte `[0-9a-f]{8}-...` pour ne pas matcher accidentellement les slugs. **`WidgetScriptController::byUuid($uuid)`** : nouvelle route publique `/widget/{uuid}.js` (canon), routes legacy `/widget/estimation.js` etc. conservées. **API publique core-city** (`WidgetController` + `WidgetDpeController` + `WidgetOpportuniteController`) : 3 endpoints `config` acceptent `?uuid=`, 3 endpoints `estimate/simulate/calculate` valident `widget_uuid` (size:36) et l'utilisent pour résoudre le widget capteur → injection `group_id` + `scope='group'` dans `Lead::create` (override le boot::creating du Lead qui ne voit pas auth() en contexte widget public). **UI list refondue** (`widget/list.blade.php`) : empty state avec 3 cards d'invitation si zéro widget ; sinon sections par type avec bouton `+ Ajouter` et cards via `_card.blade.php` (badge agence si user ≥2 agences, toggle is_active, bouton config, bouton suppression). Modale `_modal_create_widget.blade.php` (nom + sélecteur agence conditionnel). **JS embed** (`src/{estimation,dpe,opportunite}/index.js`) : détection enrichie `data-uuid OR data-token`, regex API_URL universelle `/widget/[^\/]+\.js`, `boundApiFetch` injecte `?uuid=` sur GET et `widget_uuid` sur POST. Code embed généré dans `_form_*` utilise `/widget/{uuid}.js` + `data-uuid` + `data-token`. **PIÈGES** : (1) routes uuid déclarées APRÈS routes legacy par type dans `/widgets/` group pour éviter les collisions ; (2) `User_widget::displayName()` calcule un rang `#N` SEULEMENT si plusieurs widgets du même type (sinon affiche juste le label type) ; (3) `User::widgetConfig` retourne `group_id`+`uuid` dans le tableau pour que les call-sites client-side puissent les exploiter ; (4) Lead créé via widget public a son `group_id` figé sur celui du widget capteur — boot::creating du Lead respecte l'override ; (5) suppression d'un widget ne supprime PAS les leads captés (group_id figé sur le lead) ; (6) `widget_uuid` query param sur `/api/v1/widget/config?uuid=…` est valide par défaut (User_widget appartient au user du token sinon fallback) — vérification `where('user_id', $user->id)` côté backend lookup ; (7) `WidgetController::configLegacy/updateLegacy/toggleActiveLegacy` re-créent un widget si aucun n'existe (seamless rétro-compat).
- **Verrouillage agence sur leads (2026-05-21)** — `leads.group_id` (BIGINT nullable) + `scope` (`'user'`|`'group'`) figés à la création par `Lead::booted::creating` depuis `auth()?->currentAgency()`. **Immutable** ensuite. Migration `core-city-user/database/migrations/2026_05_21_000001_add_group_id_to_leads.php` (exécutée depuis `/administration`). Relation `Lead::agency()` → `Group`. **Toutes les automations pivotent sur `$lead->group_id`** (au lieu de `$user->currentAgency()`) : `Lead_status::getEffectiveForLead($lead)` / `findByBaseStatusForLead` / `getInitialStatusForLead` (helpers ajoutés), `Lead_task_template::getEffectiveForLead`, `HasLeadHub::updateLeadStatusWithHubDual` (cas owner), tous les `Modele::findForKindScoped` (5 call-sites : `AcquisitionController` 1041/1964/3060/3290/6840 + `NotifyClientPortalEvent.php:95`), `Fee::getCalculateFees` (passe `$lead->group_id` dans `FeeController::194`). **UI** : kanban + list filtrent par défaut `group_id == currentAgency() OR (NULL AND user_id == me)` (cf. `HasLeadHub::kanbanData` + `HasLead::listLeadsPaginated/countLeads`) ; toggle `.acq-shared-cross-toggle` "Partagés avec moi" lève le filtre via `include_shared_cross_agency=1`. Badge `.lead-cross-agency-badge` dans `_lead_header_labels.blade.php` + bandeau `.lead-cross-agency-pipeline-banner` dans `_lead_status_pipeline.blade.php` (rendus uniquement si `lead.group_id !== currentAgency()`). `_lead_status_pipeline.blade.php` lit `Lead_status::getEffectiveForLead` (au lieu de `getEffectiveFor($authUser)`). **Pas de backfill** : leads pré-2026-05-21 restent `group_id=NULL` → fallback pipeline perso du créateur. PIÈGES : (1) widgets core-city publics (sans `auth()`) créent des leads avec `group_id=NULL` — comportement OK (lead "perso" capté) ; (2) le code dual-pipeline `viewer_status_id` (Sprint 2026-05-20) reste actif pour rétro-compat des leads sans group_id ; pour les leads agence, le pipeline du lead s'applique uniformément aux assignées ; (3) `Lead_status_trigger::fire()` est scope-agnostic par design (filtre par `from_status_id` seul) — automatiquement cohérent avec le pipeline du lead ; (4) commandes scheduler (MatchAlertsDigest, Digest*, ColdFollowup) restent pivotées sur le user (templates système globaux, `getEffectiveFor($user)` pour les statuts actifs).
- **Mode `prestataire_services` (2026-05-19)** — 6e mode pro pour métiers non-immobiliers (photovoltaïque, électricien, climaticien, télécom, bornes Tesla/Vinci, notaire, promoteur, BE, diagnostiqueur, géomètre-expert, etc.). **36 catégories d'activité** groupées en 10 optgroups dans `config/pipeline_presets.activity_categories`. Nouveau champ `core.user_legal_notices.activity_category` (migration `core/database/migrations/2026_05_19_120000_add_activity_category_to_user_legal_notices.php`, exécutée depuis `/administration`). **Helpers core** dans `HasAgency` trait : `User::isHoguetCompliant()` (true sauf pour `particulier`/`prestataire_services`), `User::isPrestataireServices()`, `User::activityCategory()`. **UI Hoguet masquée** : bandeau `mandate_block_reason` + section "Documents Hoguet" (boutons mandat/bail/compromis/préavis/bon visite/avenant/SRU) dans `_lead_quick_actions.blade.php` via `@if($isHoguetCompliant)`, sidebar "Registre des mandats" dans `template.blade.php` masqué via `@if(auth()->user()->isHoguetCompliant())`. **Backend protégé** : nouveau middleware `App\Http\Middleware\EnsureHoguetCompliant` (alias `'hoguet'` dans `Kernel.php::routeMiddleware`) appliqué sur 9 routes : `POST /acquisition/lead/{id}/{mandat|compromis|bail|bon-visite|preavis|sru-retractation}/generate`, `POST /acquisition/lead/{id}/mandat/{id}/avenant`, `POST /acquisition/lead/{id}/bail/generate`, et toutes les 5 routes `/registre-mandats/*`. **MandateRegistry** (core-city-user) : `assertCanGenerateMandate()` throw `MandateComplianceException` early si prestataire ; `checkMandateBlockReason()` retourne `null` early si non-Hoguet. **Conformité simplifiée** : `visibleByStatus['prestataire_services'] = ['identity', 'activity', 'preview']` dans `setting-agency.js` ; nouvelle section `.cf-section-activity` dans `conformite.blade.php` (dropdown 36 catégories). **Wizard adaptatif** : `data-default-label`/`data-prestataire-label` sur les options vente/location, swap via `applyPrestataireLabelsIfNeeded()` au `shown.bs.modal` ; pour prestataire les labels deviennent "Achat / Vente foncier" et "Location / Bail long terme". **Preset générique 7 statuts** : Prospect identifié → Contacté → Qualifié → Étude/Devis → Contrat → Réalisé → Abandonné (9 tasks standard, 11 triggers standard exploitant les 4 `requirement_type`). **`PipelinePresetService::resolveProMode()`** accepte prestataire_services. **`proModeLabel()`** enrichi avec catégorie d'activité (ex: "Prestataire de services (Installateur panneaux solaires)"). PIÈGES : (1) migration core EXCLUSIVEMENT depuis administration ; (2) `composer dump-autoload` requis sur core + core-city-user + sites consommateurs après modif `HasAgency` ; (3) modale `_modal_choose_mode.blade.php` étendue avec validation `activity_category` requis si mode=prestataire ; (4) `_legal_status_select.blade.php` étendu avec l'option `prestataire_services`.
- **Sprint C.2 (2026-05-19)** — F4 Loi Climat warnings only (voir `.insights/sprintC2_F4_climate_LIVRE.md`) :
  - **Service métier** `Rootsud\City\User\Services\Climate\EnergyClassPolicy` (core-city-user) + `config/climate.php` paramétrable (dates 2025/2028/2034 + textes mention PDF). Pas de migration.
  - **Accessor `Lead::climate_warning`** mémoïsé, scope LESSOR/TENANT uniquement, retourne `{level: 'gel_loyer'|'forbidden_rent', dpe_letter, message, pdf_mention}` ou null. Pattern identique à `mandate_block_reason`.
  - **Eager-load** `'estimation.criteria'` dans `HasLeadHub::kanbanData/kanbanColumnMore` pour éviter N+1.
  - **UI warnings non-bloquants** : badge kanban `kanban-flag-climate-forbidden` (⛔ rouge) / `kanban-flag-climate-gel` (❄️ ambre) sur `.kanban-card-flags` + badge pill `lead-climate-badge` dans `_lead_header_labels` + bandeau `alert-danger`/`alert-warning` dans `_lead_quick_actions` (sous le bandeau Hoguet, note explicite "Vous pouvez tout de même générer un mandat / bail — l'action sera tracée pour audit.").
  - **Audit trail** : nouvelle méthode `AcquisitionController::logClimateOverrideIfApplicable(Lead, action)` qui crée une `Lead_activity` TYPE_NOTE + `metadata.legal_override.policy='climate_resilience_2021'`. Hooks dans `generateMandat`, `generateMandatGestionLocative`, `generateBail`, `generatePreavis`. Aucun 422.
  - **4 variables template** injectées par `HasModel` (Sprint C 3.2 parser `{{#if}}` compatible) : `is_climate_forbidden_rent`, `is_climate_gel_loyer`, `climate_dpe_letter`, `climate_mention_block`. **LIVRÉ** : injectées dans 5 templates `Modele` système (72 mandat_location, 76 bail_location, 106-108 préavis) via migration `core-city-user/database/migrations/2026_05_19_000002_inject_climate_mentions_in_legal_templates.php` (idempotent, marqueur HTML stable `data-climate-mention="1"`). Bloc verbeux pour mandat+bail, bloc compact pour les 3 préavis. Pas légalement obligatoire (Loi Climat sanctionne le non-respect, pas l'absence de mention) mais utile au devoir de conseil Hoguet (art. 4-1 décret 72-678).
- **Sprint C.1 (2026-05-18)** — Quick wins fiche lead + portail (voir `.insights/sprintC1_quickwins_LIVRE.md`) :
  - **Portail légal** : 3 routes publiques `/portail/{mentions-legales,cgu,cookies-rgpd}` + `ClientPortalLegalController` + layout dédié `client_portal/legal/layout.blade.php` (branding agence via `?token=…` validé, fallback Rootsud). 3 liens dans `_footer.blade.php` (`.cp-footer-legal`)
  - **Portail vendeur/bailleur** : 2 nouveaux partials `_estimation_section.blade.php` (supporte les 2 formats `sections.estimation` + `sections.documents.estimation` avec `download_doc_id`) et `_photos_section.blade.php` (lightbox `:target` CSS-only). Le bloc estimation dans `_documents.blade.php` Sprint C 3.4 retiré (doublon)
  - **Kanban Hot Buyer** : précompute `withCount('hotBuyerTasks as hot_buyer_count')` dans `kanbanData()` (core-city-user) → attr `data-hot-buyer="1"` sur `.kanban-card` + badge `.kanban-flag-hot-buyer` (icône fire rouge `#d92550`). Toggle `.acq-hot-only-toggle` dans toolbar acquisition + handler `click.hotOnly` dans `acquisition.js` (filtre client-side, pas de reload). Badge pill dans `_lead_header_labels.blade.php`. Constante centralisée `Lead::HOT_BUYER_TITLE_PREFIX = '🔥 Hot buyer'`.
  - **Message template endpoint** : nouveau `GET /acquisition/lead/{id}/message-template?kind=xxx` → `AcquisitionController::getMessageTemplate` (résout via `Modele::findForKindScoped` + `replaceModelVariables` + strip HTML préservant `\n` entre blocs)
- i18n : fr (principal) + en, recherche full-text PostgreSQL tsquery config french
- **Sprint 1 (2026-05-08)** : completer fiche lead V3 + portail client (5 blocs livres) :
  - Bloc 1 : 3 hooks `Lead_status_trigger::fire()` cables — `avis_valeur_sent` + `mandat_signed` dans `HasProposal::addProposal()` (core-city-user), `advert_linked` dans `AcquisitionController::locateAdvert()`. Tests Feature LeadStatusTriggerTest (8 assertions OK)
  - Bloc 2 : envoi du lien portail au client par email + SMS via modal compose (`POST /portal/{tokenId}/send`, `GET /portal/{tokenId}/preview-send` dans `ClientPortalController`). Reutilise `SendModelByMail` + `SmsService` + nouveau template systeme `KIND_PORTAL_LINK_INVITATION` (email + variant SMS). Modal inline dans `client-portal-admin.js` (pas de blade separe pour simplicite)
  - Bloc 3 : Quick action "Envoyer estimation" reelle (`POST /acquisition/lead/{id}/send-estimation`). Modal mini email/SMS/both dans `client-portal-admin.js`
  - Bloc 4 : Bulk actions matches (`POST /acquisition/lead/{id}/matches/bulk-action`) email d'abord, SMS aussi. Replace les "bientot disponible" placeholders dans `acquisition.js _runMatchAction`
  - Bloc 5 : Bon de visite signature workflow CRM pro (loi Hoguet, eIDAS niveau 1) :
    - Migration `add_metadata_to_lead_proposals` (colonne `metadata JSON nullable` sur `lead_proposals`)
    - Constants `Lead_proposal::STATUS_BY_TYPE[TYPE_SEND_MAIL]` etendues : `pending_sign=5`, `signed_electronic=6`, `signed_paper=7` (idem TYPE_SEND_DOWNLOAD)
    - Nouveau template DOCUMENT systeme `KIND_BON_VISITE` (HTML legal Hoguet avec `{{signature_block}}`)
    - Nouveau event `bon_visite_signed` dans `Lead_status_trigger::EVENT_CHECKS` (avec checker)
    - Endpoints : `POST /acquisition/lead/{id}/bon-visite/generate` (3 modes: download/sign_portal/send_only), `POST /client/{token}/proposal/{id}/sign` (signature electronique eIDAS 1), `POST /acquisition/lead/{id}/proposal/{id}/mark-signed-manual` (validation papier)
    - Frontend : nouveau partial `_lead_bon_visite.blade.php` dans tab Pilotage + section "A signer" en tete dans portail public `_documents.blade.php` + modal signature (signature_pad CDN + 4 polices manuscrites)
    - Reutilise `signature_pad` v5.1.3 deja installe (composant existant `ecadSignatureCanvas` dans `modal_create_pdf_ecadastral.blade.php`) — meme pattern: dessin tactile + 4 polices manuscrites Dancing Script/Caveat/Sacramento/Great Vibes + bouton effacer
  - Extension `HasModel::pdf($id, $user, $site, $lead, $storage, array $extraVars = [])` + `replaceModelVariables(..., array $extraVars = [])` pour injecter `signature_block` au moment de la signature. Fallback `$model->html` si `$params->html` vide
  - Bonus : avatar agent dans `buildContactsSection` via `HasLogo::logo()->item->getUrl()` (TODO Phase 3 leve)
- **Sprint Estimation depuis lead — verrou type + boutons + envoi background (2026-06-01)** : 5 corrections sur l'onglet estimation (fiche parcelle `#colCard`) et la section Estimation de la fiche lead (`_lead_estimation.blade.php`).
  - **(A) Verrou sélecteur Vente/Location** : quand l'estimation est liée à un lead VENDEUR (`Lead::REQUIREMENT_SELLER=2`) ou BAILLEUR (`REQUIREMENT_LESSOR=4`), le bloc `.estimation-type-selector` (dans `card_estimate_unified_modern.blade.php`, lui-même dans `.unified-estimate-module`) est **masqué** (`d-none`) et **prérempli** selon le type (SELLER→`SOURCE_TYPE_SALE=0`, LESSOR→`SOURCE_TYPE_LOCATION=1`). Backend = source de vérité car `postEstimation()` fait `$('.unified-estimate-module').replaceWith(response.price)` (tab-estim.js) avec `response.price = view('card_estimate_unified_modern')` (`EstimationController::calculate`) → tout `d-none` JS serait réécrasé. `TabEstimController::index` (scénario A, ouverture depuis lead) + `EstimationController::calculate` (scénario B, sélection manuelle via LinkPicker + recalculs) calculent `$lockSourceType` + forcent `sourceType` ; le Blade applique `{{ ($lockSourceType ?? false) ? 'd-none' : '' }}`. Frontend optimiste : `tab-estim.js` refactor du handler `.estimation-type-btn` en `applyEstimationType(sourceType, {silent})` (silent = pas de postEstimation) ; `applyLeadEstimationData` lit `lock_source_type`/`source_type_hint` (exposés par `FeeController::getLeadEstimationData` au niveau racine, **même sans estimation**) AVANT l'early-return → `applyLeadSourceTypeLock()` masque+présélectionne ; `onRemove` du LinkPicker ré-affiche + reset Vente.
  - **(B) Reset prix périmé** : `TabEstimController` ne charge `$leadEstimation` que si `source_type` == sourceType résolu (aligné `Lead::hasMatchingEstimation()`) ; la branche "pas d'estimation correspondante" **unset price/price_low/price_high/ppm/ppm_low/ppm_high** (avant : seul ppm) → un lead passé vendeur→bailleur ne garde plus le prix de vente (ex: 500 000 €) en mode location.
  - **(C) Bouton `#btnGeneratePdfEstimate` suit le LinkPicker** : helper `setEstimationButtonMode(hasLead)` (tab-estim.js) bascule "Enregistrer l'estimation" (`estimation-save-mode`, `data-url=/action/parcel/estimation/save`, save SANS PDF) ↔ "Générer Avis de Valeur" (`/action/parcel/avis`). Appelé à l'init (`#estimationLinkType`), `onSelect`(lead) et `onRemove`. `actions.js::postSaveEstimation` ne génère **plus** le PDF auto (création estimation seule + reload fiche lead).
  - **(D) 3 boutons section Estimation** (Modifier / PDF / Envoyer) : un seul bouton PDF `.ld-estimation-pdf` (`data-pdf-ready` + `data-download-url`). À jour → `window.downloadURL()` direct ; absent/périmé → `/action/parcel/avis` + `DocumentHub.create('avis','valeur',taskId)` **non-silent** (modal `#modalDocumentHub` + auto-download à la complétion ligne 366) + reload. (Ancien `.ld-generate-estimation-pdf` inline + 4e bouton "dernière version" supprimés.)
  - **(E) Envoyer = régénération + envoi 100% background** : `.ld-send-avis-valeur` ouvre la modale directement (plus de pré-génération). `sem-submit-btn` (client-portal-admin.js) ferme la modale + toast "Envoi en cours…" puis POST `/send-estimation`. `AcquisitionController::sendEstimation` vérifie la fraîcheur (`is_pdf_generated`+`pdf_path` sur disque+`User_document` valuation) : frais → joint + `SendModelByMail::dispatch` ; périmé/absent → renvoie `regen_required`+`regen{proposal_id,model_id,contact_email}` (sans joindre de PDF obsolète). Le front déclenche alors `/action/parcel/avis` avec `send_after_proposal_id`/`send_model_id`/`send_contact_email` → `ActionController::parcel_avis_valeur` construit `$sendPayload` → **`core-city/ParcelsAvisCompleted` (8e param `?array $sendPayload`)** dispatche `SendModelByMail` avec le PDF fraîchement généré une fois `ParcelsAvisCompleted` terminé.
  - **PIÈGES** : (1) `toast(response, type='error', defaultText)` du core — le **message = 3e arg** (`toast(null, type, message)`), et `name='Succès'` si `type=='success'` sinon **`'Erreur'`** → ne PAS utiliser `'info'`/`'warning'` pour un message neutre (titre "Erreur" trompeur), préférer `'success'`. (2) `saveEstimationData()` lit `$estData` depuis les `parcel_user_details` **stockés** (pas la requête) → régénérer avec `{p, link_type, link_id}` minimal ne corrompt pas l'estimation. (3) `ParcelsAvisCompleted` dépend déjà de core-city-user (models) → référence `\Rootsud\City\User\Jobs\SendModelByMail` OK ; 8e param optionnel rétrocompatible (rs_captimmo `EstimationPdfController` passe 6 args). (4) `#modalDocumentHub` est dans `page_map_container.blade.php` (layout global) → dispo depuis la fiche lead. (5) Code mort supprimé : `Acquisition._initEstimationPdfProgress` + globals `_estimationPdfTaskId`/`_estimationPdfLeadId` + element/SCSS `.lead-estimation-progress`/`.lead-estimation-actions`/`.lead-estimation-pdf-link`.
- **Sprint Omnibar — clic résultat Estimation (2026-06-01)** : le scope « Estimation » de l'omnibar est désormais cliquable (avant : `case 'estimation': break;` vide dans `omnibar.js`). **Routage** : si l'estimation est liée à un lead → ouvre la fiche lead sur l'onglet Estimation (`localStorage('lead_detail_active_tab')='estimation'` AVANT `showLeftMenu(acquisition)`+`Acquisition.showDetail(leadId)`) ; sinon → modale autonome `#modalEstimationDetail`. **Backend** : `core-city/src/Http/Controllers/SearchController.php::searchJson` expose `lead_id` (eager-load `with('lead:id,estimation_id')` + `optional($e->lead)->id`) et `multiparcel_id` dans le mapping JSON estimation. **Partial `_lead_estimation.blade.php` rendu estimation-centric** : accepte `$estimation` (+ `$lead=null`, `$context`) — `$leadId`/`$estId`/`$cadastreSource` dérivés ; en `context='modal'` le `<h6>Estimation</h6>` est masqué (doublon du header modale) + bouton `.ld-send-avis-valeur` (Envoyer) masqué + bloc `.ld-edit-price`/`.ld-recompute-fees` masqué (édition lead-centric, `@if($price>0 && $leadId)`) ; boutons portent `data-estimation-id`+`data-cadastre-id` quand pas de lead. **Honoraires sans lead** : `FeeController::computeEstimationFees($estimation)` (+ `computeEstimationAutoFees`, cœur partagé `computeAutoFeesCore($estimation,$userId,?$groupId)` mutualisé avec `computeLeadAutoFees`) — barème **perso** de l'agent (`group=null`, les `estimations` n'ont pas de `group_id`). **Endpoint** `GET /acquisition/estimation/{id}/panel` (regex `[0-9]+`, AVANT catch-all `/acquisition/{map?}`) → `AcquisitionController::estimationPanel` (ownership `where('user_id', auth)` + `archived=false`) rend le partial en `context='modal'`. **Modale** `acquisition/_modal_estimation_detail.blade.php` (style aligné modalAddLead/modalParcelNote : `modal-side-right`, icon-wrapper, thème `bg-to-slider/switch/button`, `modal-header border-0`) dans `page_map_container.blade.php`. JS : `omnibar.js` (`buildCrmAttrs` ajoute `data-ob-leadid`/`data-ob-cadastre` pour estimation, helper `openEstimationDetailModal(id)` GET panel→inject `#modalEstimationDetailContent`→`showModal`), `acquisition.js` (handlers `.ld-open-estimation`+`.ld-estimation-pdf` rendus context-aware : sans lead → `showParcelCard(cadastre,true)` / POST `/action/parcel/avis` sans `link_type:lead` + reload du panel modale). SCSS `.lead-estimation-modal .lead-section-header` (acquisition.scss, auto-suffisant car la règle de base est imbriquée sous la fiche lead). **PIÈGES** : (1) le panel se rend bien `context='modal'` SANS Envoyer (vérifié tinker + DevTools) ; (2) régression nulle sur l'onglet estimation de la fiche lead (toujours `['lead'=>$lead]`+`computeLeadFees`) ; (3) `vendor/rootsud/core-city/` est PÉRIMÉ — la modif `searchJson` est dans `core-city/src/` (override PSR-4) ; (4) `Estimation` n'a PAS de `group_id` → honoraires perso uniquement en modale.
  - **Fix Modifier hors /acquisition + réouverture après édition (2026-06-01, suite)** : (A) les handlers `.ld-open-estimation`/`.ld-estimation-pdf` de la fiche lead sont dans `Acquisition.bindEvents()`, appelé UNIQUEMENT à l'init du menu acquisition (menu-map.js) → sur `/dashboard` (ou tout autre menu) ils n'étaient pas bindés et « Modifier » dans la modale ne faisait rien. **Solution** : `omnibar.js::bindEstimationModalActions(estId)` (appelée par `openEstimationDetailModal` après chargement du panel) bind les 2 actions **scopées à `#modalEstimationDetail`** (donc actives sur toutes les pages) avec `e.stopPropagation()` (évite le double-fire avec les handlers document d'acquisition.js quand on est sur /acquisition — le handler scopé modale est plus proche dans le DOM, stopPropagation empêche le bubbling vers document). (B) **Réouverture de la modale rafraîchie après édition** : impossible via `shown.bs.modal`/`hidden.bs.modal` sur `#colCard` car **`#colCard` est ouvert par une jQuery d'un AUTRE bundle** que celui d'omnibar (les events plugin Bootstrap jQuery ne sont PAS des events DOM natifs → ne traversent pas les instances jQuery). **Solution** : flag global `window._estimationModalReturnId` posé au clic Modifier ; helper global `window.reopenEstimationModalAfterEdit()` (omnibar, idempotent via le flag) appelé depuis les fonctions **du même bundle app** : `closeParcel()` (tab-init.js, = bouton X `.btnCloseParcel`) et `postSaveEstimation()` success (actions.js, branche `else` quand pas de leadId). `window.openEstimationDetailModal` exposé global. **PIÈGES suite** : (5) NE PAS écouter `shown/hidden.bs.modal` cross-bundle — hooker les fonctions JS app (`closeParcel`/`postSaveEstimation`) ; (6) jQuery namespace : un handler `.one('shown.bs.modal.estReturn')` ne se déclenche PAS sur un `trigger('shown.bs.modal')` (les namespaces du handler doivent être un SOUS-ENSEMBLE de ceux déclenchés) — piège évité en abandonnant les events Bootstrap ; (7) `setEstimationButtonMode` : estimation standalone sans lead → bouton souvent en mode avis (`postAvis`, ne ferme PAS colCard) ; le bouton save-mode (`postSaveEstimation`) ferme colCard → les 2 chemins de réouverture (closeParcel + postSaveEstimation) couvrent save ET fermeture manuelle.

- **Popup d'accueil / onboarding — stepper 3 étapes (2026-06-04)** : à la 1re connexion (flag `user_settings.onboarding_welcome_done` absent, **scope user strict**), une modale stepper s'ouvre **sur n'importe quelle page de l'app** (dès que `#modalWelcomeOnboarding` est dans le DOM — rendue partout via `page_map_container` @push('modal') : `/` SSR, `/dashboard`, `/acquisition`, `/cadastre`, etc.) tant que l'étape 1 obligatoire n'est pas faite. **⚠ PAS d'auto-validation des comptes existants (rectifié le 2026-07-30 ; ce paragraphe décrivait un comportement RETIRÉ le 2026-06-08)** : `vendor.blade.php` **n'écrit jamais** le flag. Il calcule seulement `$__obShow` (= flag absent) et `$__obProModeConfigured`. Le SEUL point d'écriture est `OnboardingController::complete()` (POST `/onboarding/complete`), déclenché quand l'utilisateur atteint l'étape 3 ou passe l'étape 2. `hasProModeConfigured()` ne décide donc PAS de l'affichage, seulement de l'**étape de départ** : vrai → étape 2 (pipeline, skippable) ; faux → étape 1 (mode pro, **non fermable**). **Rollout assumé : « tout le monde une fois »** (décision produit 2026-06-08, reconfirmée le 2026-07-30) - le choix du mode pro est désormais obligatoire, donc tout compte existant sans mode passe par l'étape 1 bloquante à sa prochaine connexion. Aucune migration de backfill du flag n'est prévue : **ne pas « réparer » ce comportement**. Mesuré en dev le 2026-07-30 : 20 853 comptes actifs, dont seulement 26 avec un mode pro configuré (23 `user_legal_notices.status` + 6 membres d'agence active, 3 en commun) et 16 avec le flag posé → **20 827 verront l'étape 1 obligatoire**, 10 l'étape 2. Le parc est ancien : l'écran de conformité qui alimente `user_legal_notices` date de mai-juin 2026. **Étape 1 (MODE, obligatoire — pas de croix/Annuler)** : réutilise les 6 modes de `setting/agence/_modal_choose_mode` (particulier/independant/mandataire_reseau/prestataire_services[+activity_category]/creer/rejoindre) → **POST `/parametres/mode`** (`AgencyController::updateMode`, body `{mode, confirmed:1, activity_category?}`). **Étape 2 (PIPELINE, skippable)** : **GET `/onboarding/scenarios`** (`OnboardingController::scenarios`, mode RE-RÉSOLU serveur via `PipelinePresetService::resolveProMode`, rend `dashboard/_onboarding_scenarios.blade.php` = cartes `.wo-scenario` depuis `scenarioCatalog`) → sélection d'un scénario uniquement (**PAS de sélecteur minimal/standard/maximal** — supprimé du produit ; le `automation_level` est **dérivé** du scénario via son `data-niveau-reco`, comme le wizard `/parametres#taches`) → **POST `/parametres/pipeline/apply-preset`** (`{entity:'user', scenario_slug, mandate_types[], automation_level (=data-niveau-reco), archive_existing:1}`). **Étape 3 (BIENVENUE, skippable)** : message perso `{fname}` + raccourcis Tutoriels (`#modalTutorial` si `$has_tutorials`) / Contact / Signaler un bug / Proposer une idée (réutilise `#modalFeedback` + `data-feedback-type`). **L'onboarding est marqué terminé DÈS l'arrivée à l'étape 3** (ou skip étape 2) → **POST `/onboarding/complete`** (`updateUserSetting($user,$this->site,'onboarding_welcome_done',true)`) ; évite tout conflit de modales empilées (tuto/feedback ouverts depuis là). **creer/rejoindre = variantes de l'étape 2 (TOUT dans la popup, AUCUNE autre modale)** : l'étape 2 est polymorphe via `showStep2Variant('pipeline'|'agency'|'join')` (3 sous-blocs `#woScenarioWrap`/`#woAgencyWrap`/`#woJoinWrap` + titre `#woStep2Title` dynamique). **creer** → formulaire agence (radios `wo_structure_type` agence/équipe + `#woAgencyName`) → `submitAgency()` POST `/parametres/agence/creer` (Group type=10, role owner) ou `/parametres/equipe/creer` (type=0) → étape 3. **rejoindre** → saisie `#woJoinToken` → `submitJoin()` extrait le token (lien/code) puis `window.location.href=/parametres/agence/rejoindre/{token}` (confirmation serveur `acceptJoin` ; NE marque PAS complete si token invalide → re-tentative possible). Variantes agency/join **non-fermables** (× + skip masqués, seul « Retour » dispo — rien n'est encore configuré) ; variante pipeline skippable (mode déjà posé étape 1). **`finish()` (« C'est parti » / skip) FERME simplement la modale — PAS de `window.location.reload()`** (le reload jarrant a été retiré ; `markComplete` pose le flag en AJAX donc 0 ré-ouverture). La sidebar (items Hoguet/agence) et les couleurs de pipeline (`window.LEAD_STATUS_COLORS` injectées au load) se rafraîchissent au **prochain chargement complet** (la nav SPA `loadPostContentMenu` ne re-rend pas `template.blade.php`) — décalage cosmétique/temporaire, rien de fonctionnel. **REJEU BILLING APRÈS FERMETURE** : si une modale billing AURAIT dû s'ouvrir au chargement (capturée dans `window._onboarding.billing` par `vendor.blade.php`), `finish()` la déclenche **350 ms après fermeture** de la popup (`showModalPlan`/`Exceeding`/`ChangeMean`, priorité plan>dépassement>moyen, une seule = pas d'empilement ; `billing` remis à `null` = pas de re-trigger). **Vérifié E2E** : creer → agence créée DANS la popup (0 autre modale, `modalCount=1`) → étape 3 → « C'est parti » ferme sans reload ; compte non abonné → `billing.plan=true` capturé, neutralisé pendant l'onboarding, puis `modalPlanPick` s'ouvre après fermeture (`noReload=true`, 1 seul backdrop) ; rejoindre → saisie token dans la popup. **Fichiers** : `OnboardingController` (2 méthodes), routes `onboarding.scenarios`/`onboarding.complete` (AVANT catch-all dashboard), `dashboard/_modal_welcome.blade.php` + `_onboarding_scenarios.blade.php` (inclus via `page_map_container` → couvre `/` ET `/dashboard`), `welcome-onboarding.js` (`window.WelcomeOnboarding`, bundlé app.js), `_welcome-onboarding.scss` (`.wo-*`, primary `#227beb`), `vendor.blade.php` injecte `window._onboarding={show,proMode}`, `dashboard.js::init()` trigger + ajout `_modal_join_agency` dans `page_map_container`. **DÉCLENCHEUR UNIVERSEL** : **poll `setTimeout` natif auto-portant** dans `welcome-onboarding.js` qui ouvre la popup **dès que `#modalWelcomeOnboarding` est présent dans le DOM** (donc sur TOUTE page map, pas seulement le dashboard) + le trigger en fin de `Dashboard.init()` (redondant, gardé). **NE PAS** utiliser `$(function(){})` pour ce trigger : welcome-onboarding.js est en fin de bundle → si un handler ready ANTÉRIEUR throw (fréquent mobile + `Dashboard.init()` lui-même gaté `matchMedia(min-width:992px)` sur `/`), jQuery interrompt la file et le nôtre ne tourne jamais → poll natif obligatoire. Garde `state.opened` = pas de double-open. **ÉTAPE 1 NON-FERMABLE** : `data-backdrop="static"` + `data-keyboard="false"` + `.wo-close`/`.wo-skip` masqués à l'étape 1 (vérifié : clic backdrop + ESC ne ferment pas). **ANTI-DÉBORDEMENT (scroll confiné à la body)** : `modal-dialog modal-lg modal-dialog-centered **modal-dialog-scrollable**` + SCSS **`max-height: calc(100vh - 3.5rem)`** sur `.modal-content` (le `3.5rem` = les `1.75rem` de marge haut+bas du `.modal-dialog` ; avec `2rem` le contenu+marges dépassait de ~24px → c'est le `.modal` ENTIER, header/footer compris, qui scrollait et poussait la PAGE) + `.modal-body{overflow-y:auto}` + override mobile `<576px` `calc(100vh - 1rem)` (marge 0.5rem). Résultat : **seul `.modal-body` scrolle**, jamais le `.modal` ni la page (vérifié viewport 620px : `PAGE_scrolls=false`, `MODAL_el_scrolls=false`, `BODY_scrolls=true`). **PIÈGE header** : le `.modal-header` portait `pb-0` (utilitaire BS `padding-bottom:0 !important`) → les step dots collaient au bas ; retiré → SCSS `.modal-header{padding:20px 24px 16px}` (header plus aéré, 16px sous les dots). **PRIORITÉ SUR LES MODALES BILLING** : quand la popup d'accueil s'affiche, on neutralise l'**auto-ouverture AU CHARGEMENT** des 3 modales billing du core (`#modalPlanPick`/`#modalUpdateMean`/`#modalUpdateExceeding`) — `vendor.blade.php` remet à 0 les globaux `SHOW_PLAN`/`SHOW_EXCEEDING`/`SHOW_CHANGE_MEAN` (définis par `base.blade.php` core, lus par le ready handler de `app-core.js`) DANS le `<script>` `window._onboarding` (qui tourne après leur définition et avant DOMContentLoaded). Fix **100% site** (pas de rebuild core). Les handlers AJAX 432-435 (bootstrap.js) restent intacts. **Les valeurs ORIGINALES de `SHOW_*` sont capturées dans `window._onboarding.billing` AVANT neutralisation** → rejouées par `finish()` après fermeture (cf. clause `finish()` ci-dessus) ET de toute façon ré-affichées au prochain chargement complet. Vérifié : `SHOW_PLAN` rendu à `1` par base → forcé à `0` → `modalPlanPick` non ouvert pendant l'onboarding, **1 seul backdrop**, puis rouvert après « C'est parti ». **NB** : commentaire explicatif en `{{-- Blade --}}` (strippé serveur), pas en `//` JS, pour ne pas fuiter dans le HTML navigateur. **Tutoriels IntroJS du site PURGÉS** : `project_site_guided_chapters/items/chapter_urls` du `site_id=2` supprimés en BDD (4 chapitres/22 items) + `cache:forget guided_tours_site_2` → `window.GUIDED_TOURS={}` ; les 13 `project_site_tutorials` (vidéos) **conservés** (`$has_tutorials` reste vrai). **Facturation électronique 2027 = PHASE 2 séparée** (la popup ne capte que le signal particulier/pro B2C↔B2B via le mode ; `prestataire_services` = pro B2B non-Hoguet). Vérifié Chrome desktop+mobile (390px) : auto-open, flux mode→pipeline→complete, `mode=independant`+pipeline remplacé (6 archivés+6 créés)+flag=1 en BDD, reload sans ré-ouverture, 0 erreur console.
- **PIÈGE cache app.js en test (2026-06-04)** : `navigate_page(type:url)` **ne busته PAS** le cache de `/js/app.js` (Apache `immutable` + pas de hash en dev) → la page ré-exécute l'ANCIEN bundle malgré un rebuild. Un `fetch('/js/app.js?nc=…')` lit pourtant le nouveau (URL différente) → faux positif. **Seul `navigate_page(type:reload, ignoreCache:true)` force le navigateur à exécuter le nouveau bundle.** Pour valider un changement JS : hard-reload (`type:reload`+`ignoreCache`), confirmer via un marqueur global temporaire (`window.__WO_BUILD`).

- **KPI de la fiche lead : 4 cartes au plus (2026-09-24)** : sur un vendeur / bailleur dont le bien a des acquéreurs, la carte « Acquéreurs » s'ajoutait aux 4 autres (5 cartes). `_lead_kpi_strip` pose `$feesMergedIntoEstimation` (Acquéreurs + Avis de valeur + Honoraires) : la carte Honoraires n'est plus rendue et la ligne de surface de la carte « Avis de valeur » devient « Honoraires : X EUR TTC » (`.lkc-fees-inline`, vert). Sans acquéreur, rendu inchange. Verifie : `.insights/kpi-strip-4-cards-2026-09-24.php` (leads 2196 et 575 : 4 cartes avec fusion ; 639 et 624 : 4 cartes sans fusion). Deploiement : `npm run prod` + `view:clear`. **Suite** : carte « Acquéreurs » refaite - lien discret « Suivi › » dans l'en-tete (`.lkc-head` + `.lkc-head-link`, meme `ld-hero-jump-tab`) au lieu d'un bouton icone pleine largeur, et « Relancer N acquéreurs » en bouton compact a largeur de contenu (`.lkc-btn-soft.lkc-btn-auto`), rendu seulement s'il y a des acquereurs a relancer. ⚠ `.lkc-fees-inline` doit etre ecrit `.lkc-meta .lkc-fees-inline` (sinon `.lkc-meta strong` l'emporte, texte noir).
- **Carnet acquereurs : ligne `bt-row` refaite (2026-09-24, suite)** : avatar a initiales teinte par le type, nom + pastille, coordonnees cliquables (`tel:` / `mailto:`, « Aucune coordonnee » sinon), libelle saisi du dossier sous le nom ; budget en gras + financement ; criteres en pastilles (type, pieces ou chambres, surface, terrain) + zones ; echeance avec delai (« Dans 3 mois », « Echeance depassee », couleurs) ; statut en pastille douce a point colore + derniere activite ; action **Matching** avec l'icone `fa-random` de l'onglet de la fiche (etait `fa-bullseye`) + chevron. Projet sans AUCUN critere = une seule cellule `bt-col-empty` (colspan 3) « Criteres de recherche a completer » (`.bt-complete`, ouvre la fiche sur l'onglet Pilotage). En panneau etroit (container query 940 px) la carte passe en grille 2 lignes (560 px : 3 lignes), sans libelles « Budget : », criteres absents masques. Squelette `buyerTableSkeletonRows` aligne sur les nouvelles cellules. Au passage (core-city-user) : `LeadRequirementCriteria::zones()` lit aussi `name` (ancien format `{code, name}` : 4 acquereurs affichaient « 66227 »), et la recherche texte sur les zones aussi. Verifie : `.insights/buyers-table-infinite-scroll-2026-09-24.py` 16/16 (constante a 5 le temps du test), interactions 12/12 (clic ligne, Matching → onglet Matching, Completer → Pilotage, case sans ouvrir la fiche, e-mail sans ouvrir la fiche), captures 1478 / 900 / 700 / 390 px. Deploiement : tag core-city-user + `composer update` (site ET administration) + `npm run prod` + `view:clear`.
- **Barre `/acquisition` en mobile (2026-09-24)** : sous 460 px la partie gauche (vues + Filtres + recherche, 184 px en kanban, ~228 en liste) debordait sur `#addLeadBtn` (chevauchement mesure de 390 a 440 px avec le libelle « Nouveau »). `acquisition.scss` : sous 460 px, bouton d'ajout en icone seule (`.acq-add-label` masque, `title` = « Nouveau lead »), `fa-magnet` du compteur masque, boutons de vue resserres ; sous 360 px, Trier/Filtres/recherche resserres (vue liste). `.toolbar-right` en `align-items: stretch` + `.export-dropdown` en flex : Export a la hauteur des autres (25 → 35 px). Verifie en iframe 320 a 480 px, kanban et liste : 0 chevauchement, 4 boutons a 35 px. Deploiement : `npm run prod` + `view:clear`.
- **Carnet acquereurs : meme defilement que la vue liste (2026-09-24)** : le tableau (`#acquisitionTableView`) defilait en `overflow:auto` natif et s'arretait a **500 lignes** avec une note de troncature. Il passe sous **PerfectScrollbar** (`.bt-scroll-area > .scrollbar-container.bt-scroll`, rails en `z-index:3` au-dessus de l'en-tete collant) et sous le **meme `InfiniteScroll` du core** que la liste (`reachEventAcquisitionTable` / `unreachEventAcquisitionTable` / `showMoreAcquisitionTable` dans `acquisition.js`, 3 pages gardees, retrait haut/bas et rechargement des pages precedentes). Cote serveur, `buyersTable` pagine par **numero de page** (`cursor`, `BUYER_TABLE_PER_PAGE = 30`, `buyerTableData($request, $limit, $offset)`) : les tris du carnet portent sur des expressions SQL (budget, echeance) qu'un `cursorPaginate` ne sait pas encoder. Avec `cursor` la route rend seulement les lignes (`buyers_table_rows.blade.php`, partage avec le rendu initial : `data-index`, `data-page-previous`, `data-page-next`, `tr.resultMore`). ⚠ Les loaders du composant sont des `<div>` places a cote du « voir plus » : invalides dans un `<tbody>`, ils sont **surcharges sur l'instance** (`showPreviousLoader` / `showNextLoader` rendent un `tr > td[colspan]`), le core n'est pas modifie. `itemsPerPage` est lu dans `data-per-page` du fragment (source unique = la constante). La selection cochee est reappliquee aux lignes chargees (`_syncTableSelection`). **Squelette dedie** (meme jour) : `buyerTableSkeletonRows(count)` rend 3 `tr.bt-skel-row` aux 7 memes cellules et classes de colonne qu'une `.bt-row` (case, nom + pastille type + contact, budget, recherche 2 lignes, echeance, pastille de statut + activite, bouton) : colonnes alignees au pixel sur les vraies lignes, 70 px de haut contre 59 a 77. ⚠ Jamais la classe `.bt-row` sur un squelette : c'est l'`itemSelector` compte et retire par `InfiniteScroll`. En panneau etroit, `.bt-skel-row` partage le style carte de `.bt-row` et ses champs tiennent sur une ligne (180 px, vraies cartes 147 a 192). Barres `.bt-skel-bone` avec reflet anime `btSkelShimmer`, coupe sous `prefers-reduced-motion`. **🟥 Correctif (retour utilisateur, meme jour)** : aller-retours avec des pages de 5 = des lignes perdues (« il manque 2 contacts », puis « il manque Bruno »). Le composant garde un NOMBRE de lignes (`itemsPerPage x maxPagesToKeep`) : la derniere page etant courte (1 ligne), il retirait la page 1 ET la 1re ligne de la page 2 ; la ligne restante annoncait « page precedente = 1 », la ligne retiree n'etait plus jamais rechargee. Surcharges d'instance : chaque ligne porte `data-bt-page`, `getMaxItemsToKeep` garde des PAGES ENTIERES (les N dernieres en descendant, les N premieres en remontant, via `_removeOldItemsTop/Bottom` enveloppes) et `updatePageTrackingAfterRemoval` compte les pages reellement retirees ; une page n'est retiree que si le reste depasse encore 1,5 fois la hauteur visible (sinon plus de barre de defilement, donc plus d'evenement « debut atteint » : bloque en fin de liste, mesure 821 px de contenu pour 821 visibles). `fillBuyerTableViewport()` (250 ms apres chaque chargement) relance la suite quand rien ne deborde (plus besoin de cliquer « Charger plus » sur grand ecran) et quand on est reste colle en bas ou en haut : un « bord atteint » recu pendant l'anti-rebond de 100 ms du composant est ignore et n'est jamais reemis. **Etendu a la vue liste le meme jour** : les garde-fous sont factorises en helpers (`installWholePageRemoval(inst, attr)`, `fillInfiniteViewport(getInst, selector)`) et appliques aux deux instances ; la liste pagine par curseur Laravel (aucun numero de page cote serveur), donc `tagAcquisitionListPages()` numerote les lignes a l'insertion (`data-acq-page` : 1er chargement = 1, page ajoutee en bas = max + 1, en haut = min - 1), appele a l'initialisation, apres `showMoreAcquisition` et apres le bouton « Charger plus ». ⚠ Les `data-index` de la liste derivent en rechargeant vers le haut (calcules depuis `last_index`, comportement anterieur sans effet fonctionnel) : ne pas s'en servir pour controler la continuite, comparer les `data-id` a la liste de reference. Harness `.insights/acquisition-list-infinite-scroll-2026-09-24.py` : reference serveur de 544 leads reconstruite par curseur, **16/16** en 1478x963 et 1478x500 (fin = pages 17-18-19 dont la derniere de 4 lignes, retour au debut = pages 1-2-3, tranche toujours continue). Harness `.insights/buyers-table-infinite-scroll-2026-09-24.py` (Chrome headless + CDP, molette reelle, `BUYER_TABLE_PER_PAGE` a 5) : **16/16** en 1478x963 et 1478x500 ; avant correctif, lignes perdues puis blocage aux bords. ⚠ Jeton autologin : `expires_at` est compare en heure de Paris, un jeton date en UTC par `now()` de psql est deja expire. Export CSV inchange (sa propre limite). ⚠ **Recette** : dans un onglet Chrome en arriere-plan, `requestAnimationFrame` ne tourne pas et le repositionnement d'`InfiniteScroll` ne se termine jamais (pagination bloquee apres le premier retrait de lignes, liste comprise) : remplacer `requestAnimationFrame` par un `setTimeout` dans l'iframe de test. Verifie avec 5 lignes par page (constante abaissee le temps du test) : pages 1 a 5 dans les deux sens, 0 `div` dans le `tbody`, fin de liste = « voir plus » masque, rendu en cartes a 420 px. Deploiement : `npm run prod` + `view:clear`.

### Points d'attention
- **Fond de carte : ne JAMAIS ecrire une URL de tuiles dans un engine ou une vue (2026-09-22)** : tout passe par `createBasemap()` de `resources/js/widget/src/map/basemap.js` (widgets) ou `window.Basemap.create()` (application). Une couche ajoutee par `addSource` / `addLayer` doit etre posee dans le callback `onStyleReady`, jamais dans un `map.on('load')` seul : `setStyle` (repli, theme) la purge. Les noms de styles VersaTiles sont `gray` / `gray-dark` / `colorful` / `colorful-dark` (les autres redirigent). Les widgets chargent MapLibre 4.3.2 : un style qui porte une propriete plus recente est rejete par la validation, d'ou `validateStyle: false` dans le module. Recette carte : le harness CDP `.insights/basemap-fallback-2026-09-22/run.py`, jamais `--virtual-time-budget` (tuiles vectorielles chargees par le Web Worker, invisibles au temps virtuel).
- **🟥 Agenda : un equipier INVITE ne voyait jamais le RDV dans son agenda, et le filtre equipe ne pouvait cocher qu'un exemplaire d'un agent present dans deux equipes (2026-09-22)** : signale « j'invite un participant, il recoit bien le mail, mais connecte a son compte je ne vois pas l'event dans son agenda » et « dans `agendaTeamFilter`, avec 2 equipes et 2 fois le meme user, je ne peux en cocher qu'un des 2 ». **(1)** `AgendaController::events` ne retenait que `whereIn('user_id', cibles)` + leads partages : l'invitation (`agenda_event_attendees`, `attendee_type = Rootsud\Core\Models\User`) n'entrait pas dans le perimetre - le .ics par e-mail etait la seule trace. Livre : `$scope` unique (proprietaire OU invite de type User OU lead partage) pour les requetes non recurrente et recurrente ; un RDV vu PAR l'invitation d'un equipier dont l'organisateur est HORS de mon equipe est occulte « Occupe » (meme branche que le RDV prive : `$organizerOutsideTeam && !viewerCanSeeDetail`), tandis que l'invite lui-meme voit tout ; `editable` reste reserve au proprietaire. ⚠ Le perimetre « equipe » de `getTeamUserIds()` vient de `getGroupsWithUsers()` (**`group_access`**, groupes non prives), PAS de `group_users` : en dev l'equipe de l'utilisateur 2 est {12, 214271, 214293} alors que `group_users` liste 13, 5... - un harness qui choisit ses temoins par `group_users` se trompe d'equipe. ⚠ Non traites : `DigestMorningCommand::buildEventsBlock` et `RdvReminderCommand` (core-city-user) restent sur `user_id`, un invite n'a ni recap ni rappel de ses invitations. **(2)** La vue rendait `id="agenda-team-{id agent}"` par groupe : deux inputs au meme id, le `label for` du second cochait le premier. Livre : id unique **par groupe** (`agenda-team-{groupe}-{agent}`), `data-user-id`, et dans `agenda.js` toutes les cases d'un meme agent refletent le MEME etat (un agent = un agenda), `_distinctTeamIds()` dedoublonne `teamMemberIds` et le compteur (`2/4` pour 5 cases), « Aucun » garde toutes les cases de l'utilisateur courant. **Verifie** : harness `.insights/agenda-attendee-visibility-2026-09-22.php` **12/12** (transaction annulee, `withoutEvents` donc 0 e-mail : invite voit le RDV non editable, tiers hors equipe occulte via le filtre, tiers ne voit rien) ; navigateur sur le fragment reel (ids uniques rendus, doublon fabrique dans un 2e groupe : clic sur le doublon coche les deux, decoche depuis le 1er decoche les deux, Tous = 4 agents distincts, Aucun = soi). Deploiement : `git pull` + `npm run prod` + `view:clear`.
- **Modale RDV : jour ferme BLOQUE + fin recalculee au changement du debut (2026-09-22)** : signale - « je peux prendre un rendez-vous le samedi alors que c'est un jour ferme dans mon calendrier, le samedi n'apparait donc pas dans la vue agenda ; et si je mets le 26/09/2026 a 10h avec une fin avant le debut, il faut la recalculer avec la duree par defaut ». **Mesure avant de coder** : les horaires (`calendar_hours_helpers.php`, `effectiveWeeklyHours` perso > agence > legacy) ne servaient qu'a la GRILLE FullCalendar (`hiddenDays`, `businessHours` = ombrage) ; ni `agenda.js` ni `AgendaController::store/update` ne controlaient le jour (validation `start required|date` + `end after_or_equal:start` seulement) - un RDV pose un samedi masque existait, envoyait ses rappels et **n'apparaissait nulle part** (11 RDV sur 67 en week-end en dev). `agenda.js` n'avait **aucun handler `change`** sur les 4 champs de date : la fin n'etait calculee que sur clic d'un type (duree du TYPE) ou clic d'un creneau (reglage `calendar_default_duration`), jamais au changement du debut. 🟥 **Hors du menu Agenda, `Agenda.settings` valait `{}`** : `window._agendaSettings` n'etait injecte que par `agenda/index.blade.php` et `Agenda.init()` sort avant l'assignation sans `#agendaWrapper` - depuis une fiche lead la duree retombait sur un 60 code en dur et la grille etait inconnue. **Livre** : helper unique `agendaCalendarSettingsFor($user,$project)` consomme par `AgendaController::index` ET par `page_map_container.blade.php` (`window._agendaSettings = window._agendaSettings || @json(...)`, adopte par `Agenda.ensureFormBindings()` quand `this.settings` est vide) ; `weeklyHoursDayIsOpen()`, `weeklyHoursDayLabel()`, `weeklyHoursClientDaysNotOpenInternally()` ; cote modale, handlers `change.agendaDates` : debut modifie → **fin = debut + duree par defaut des options** (`calendar_default_duration`, a defaut duree du type selectionne, a defaut 60 - `_syncEndFromStart`). ⚠ **Retour utilisateur apres la 1re livraison** : la regle « conserver la duree precedente » reportait un ecart de 25 h (debut 22/09 09:00, fin 23/09 10:00, debut → 24/09 09:00 donnait fin 25/09 10:00) - abandonnee au profit de la duree des options ; une fin sur un autre jour reste possible en la modifiant APRES le debut. Fin passee avant le debut → recalculee de meme (`_ensureEndAfterStart`) ; « Journee entiere » ramene la fin au jour du debut ; jour ferme (debut OU fin) → `is-invalid` + `#agendaClosedDayMsg` « Le samedi est ferme dans vos horaires » + lien « Modifier mes horaires » (`/parametres?tab=preferences`) + bouton Enregistrer desactive + garde dans `saveEvent` (0 requete meme bouton reactive de force). **Filet serveur** `AgendaController::closedDayViolation()` → `422 {error:'closed_day', field, day_label, date, message}` dans `store` et `update` (les 3 `.fail` d'`agenda.js` le rendent) ; horaires INTERNES ; fin a `00:00:00` strictement apres le debut = borne EXCLUSIVE (drag-drop FullCalendar d'une journee entiere : le jour controle est la veille). ⚠ **En edition, seules les dates qui CHANGENT sont controlees** (`_loadedEventDates` cote JS, `$ignoreDates` = dates deja portees par l'event cote serveur) : un RDV existant pose un samedi reste modifiable (titre, heure, participants) tant qu'on ne le deplace pas vers un autre jour ferme. ⚠ **Coherence client ⊆ interne** : une visite acceptee depuis le portail (`client-portal-admin.js` POSTe `/agenda/events`) atterrit dans l'agenda interne, ou un jour ferme est refuse → `SettingController::saveCalendarHours` refuse (422 `weekly_hours_client`, message nommant les jours) un jour ouvert aux clients mais ferme en interne. Des horaires differencies incoherents **deja enregistres** avant ce lot feraient echouer l'acceptation d'une visite ce jour-la : le message dit d'ouvrir le jour. ⚠ Ne pas confondre les deux « durees par defaut » : `agenda_event_types.default_duration` (par type : Visite 45, Appel 30, Signature 90, Formation 180...) prime sur le reglage de compte des qu'un type est selectionne, et un type est presque toujours preselectionne (`calendar_default_event_type`). **Verifie** : harness HTTP rejouable `.insights/agenda-closed-day-2026-09-22.sh` **20/20** en session reelle (samedi et dimanche 422, lundi 200, fin multi-jours sur samedi 422 `field=end`, titre seul d'un RDV du samedi 200, deplacement vers un autre samedi 422 et base intacte, journee entiere vendredi a fin exclusive 200, horaires client incoherents 422 puis coherents 200 ; reglages et RDV restaures) ; navigateur sur le bundle recompile, **depuis `/agenda` ET depuis `/acquisition` via `openAgendaCreate`** (13 etats : blocage samedi debut/fin/journee entiere, retour, fin = debut + 60 (cas utilisateur 22/09 09:00 → 23/09 10:00, debut → 24/09 09:00 donne 24/09 10:00 ; heure → 14:30 donne 15:30), fin avant debut → recalculee, fin multi-jours saisie apres le debut conservee, edition du RDV existant du samedi non bloquee puis bloquee sur un autre samedi ; clic Enregistrer un samedi = **0 requete** meme bouton force, lundi = POST 200) ; PHPUnit 160 / 3 echecs preexistants ; 0 erreur console ; 0 ligne de log ; base rendue a l'identique. Deploiement : `git pull` + `npm run prod` + `view:clear` (0 migration, 0 route, 0 package).
- **🟥 « Signer le compromis envoyait le lead en Vendu » : `base_status_id` des presets = statut legacy, jamais un ordinal (2026-09-22)** : signale sur le pipeline « Vendre un bien ». Le moteur et la migration de realignement vivent dans **core-city-user** (cf. son CLAUDE.md : checker `acte_authentique_signed` factuel, `Lead_status::normalizeBaseStatusId`, `matchByBase`). **Cote site** : `config/pipeline_presets.php` (en-tete documente la regle) - vente pro `compromis` 4 → **3**, `vente` 5 → **4**, `abandonne` 6 → **5** ; mixte `compromis_bail` → 3, `conclu` → 4 ; particulier `vendu`/`loue` 2 → 4, `abandonne` 3 → 5 ; particulier mixte `conclu` → 4 ; prestataire `rdv_realise` → 4, tous les `abandonne` → 5 ; `config/pipeline_scenarios.php` : `acquis` (chasseur) → 4, les `abandonne` null → 5, les won intermediaires du funnel (`estimation`, `recherche`) restent **null** a dessein. `PipelinePresetService` (creation + `previewPresetStatuses`) et `SettingController::storeStatus` (colonne creee a la main : lost → 5, sinon null) passent par `normalizeBaseStatusId`. ⚠ **Regle** : une colonne active vaut 0..3 (3 = mandat signe, vente en cours), un won 4 (ou 0/null pour un won intermediaire de scenario), un lost 5 - `fire()` recopie ce base dans `leads.status`, lu par une trentaine de consommateurs (capacites, commandes planifiees, portail, matching). Test de garde `LeadStatusTriggerTest::test_pipeline_presets_and_scenarios_carry_legacy_base_status_ids` (+ 2 tests : checker factuel, regle de normalisation). Harness A/B `.insights/pipeline-compromis-acte-2026-09-22.php` (legacy 14/14 defaut reproduit, fixed 29/29), E2E HTTP `.insights/pipeline-compromis-acte-e2e-2026-09-22.sh` **16/16** (signature manuelle → Compromis, `PUT /note` → toujours Compromis, `close-sale` → Vendu), PHPUnit 160 / 3 preexistants, `validate_configs.php` 0 erreur. **Deploiement** : tag core-city-user + `composer update` (site ET administration) + `migrate` + `queue:restart`, puis `git pull` + `config:clear` + `view:clear` ; 0 route, 0 JS, 0 build.
- **Renommer un dossier lead : `leads.title` + libelle unique `Lead::display_name` (2026-09-21)** : demande - « dans la fiche d'un lead je dois pouvoir le renommer ; dans `bc-label-text` on affiche l'adresse concatenee au numero de parcelle, il faut que ce soit toujours cela sauf si on renomme ; et dans le kanban et la liste, a la place de l'adresse, on affiche ce titre aussi ». **Mesure avant de coder** : sur 705 leads, **453 ont une parcelle mais aucune adresse et 40 n'ont ni l'une ni l'autre, soit 493 (70 %)** - pour eux la carte du kanban n'affichait **aucun libelle** (le bloc etait conditionne par `@if($lead->adresse_complete)`) et la liste affichait « Adresse non renseignee » : le besoin depassait le renommage. **Livre** : colonne `leads.title` + accesseurs `display_name` / `default_display_name` (cascade unique, cf. `core-city-user/CLAUDE.md`), consommes par les **3 surfaces** demandees. Cote site : route `PUT /acquisition/lead/{id}/title` (`updateLeadTitle`, calquee sur `updateLeadNote`, **sans `planaccess:`** - renommer releve d'`ACCESS_LEAD`, le mettre derriere `ACCESS_CRM` en priverait **38 plans sur 40** ; garde `Lead::sharedFor`, 404 sur un lead d'autrui, `max:120`, chaine vide = retour au libelle calcule) ; item **« Renommer le dossier »** en tete du menu du fil d'ariane, avant « Modifier le projet » (qui touche au metier) ; handler delegue `click.ldRename` avec un dialog **Swal direct** (champ pre-rempli, invite = libelle calcule), puis `BreadcrumbNav.setLabel` + `refreshLeadInView`. **🟥 Defaut latent corrige au passage** : le fil d'ariane etait rempli par un **scraping du DOM** de la fiche (`.lead-req-chip-zone .lead-req-chip-value`), donc il tombait sur « Lead » des que le bloc « contexte du bien » n'etait pas rendu - il lit desormais `breadcrumb_label`, resolu par le serveur. ⚠ **Le titre est un libelle INTERNE a l'agent** : il ne touche jamais `adresse_complete` (variable `{{lead.adresse}}` des mandats, compromis et baux, adresse montree au client dans son espace et sur l'ecran de signature) - verifie, pas suppose. ⚠ **Le tri « Adresse A-Z » reste sur l'adresse** (son intitule le dit) : ne pas le passer en `orderByRaw(COALESCE(...))`, `cursorPaginate` encode son curseur depuis une COLONNE de la ligne. ⚠ **Un champ laisse vide VALIDE = retour au nom par defaut**, ce n'est pas une annulation. **Verifie** : harness **19/19** en transaction annulee (`.insights/lead-title-rename-2026-09-21.php`), non-fuite **6/6** (`.insights/lead-title-no-leak-2026-09-21.php` : `{{lead.adresse}}` rend l'adresse, `buildKeyData`/`buildSummary` du portail n'exposent pas le titre, et **aucun package ni vue du portail ne lit `display_name`**), **E2E HTTP 15/15** sur les vraies routes en session reelle (`.insights/lead-title-rename-e2e-2026-09-21.sh` : lead possede, lead partage, lead d'autrui 404, 121 caracteres refuses, adresse intacte), navigateur (menu, dialog, renommage reel, retour au defaut, **les 6 cartes du kanban portent desormais un libelle**, recherche par titre en liste ET au kanban, annulation qui n'ecrit rien, 0 erreur console), PHPUnit **157 / 3 echecs preexistants**, base rendue a l'identique (0 titre residuel). ⚠ **Deploiement** : tag core-city-user + `composer update` (ce site ET administration) + `php artisan migrate` depuis administration + `queue:restart`, puis `git pull` + **`npm run prod`** + `view:clear` + `route:clear` ; captimmo (1.0.25) exclu. **Suite le meme jour (2 retours utilisateur)** : « il faudrait un moyen plus rapide, peut-etre un petit crayon a la fin de `bc-label-text` » et « plus gros probleme, en mobile le `bc-label-text` est quasiment totalement masque sur le lead 648 car les `lead-hero-badge` prennent de la place ». **Mesure a 390 px sur ce lead AVANT de coder** (iframe same-origin : `resize_window` est sans effet sur une fenetre maximisee) : sur un libelle de 385 px, **53 px visibles, soit 14 %** ; les badges de source et d'etat occupaient **94 px des 153 px** du crumb (**61 %**) et le crumb racine « Acquisition » 58 px de plus. Variantes mesurees une a une : badges masques 153 px, crumb racine masque 125 px, **les deux 225 px**. **Livre** : (a) **crayon** `.bc-label-edit` pose a la suite du libelle par `Acquisition._setBreadcrumbRename` (meme classe `.ld-rename-lead` que l'item du menu, donc **aucun second handler** ; retire par `_clearBreadcrumbRename` quand le fil d'ariane quitte la fiche ; apres renommage, **tous** les points d'entree du lead sont remis a jour, sinon le second rouvrirait l'ancienne valeur) ; (b) `.breadcrumb-crumb-label.has-bc-rename` passe en **flex** avec l'ellipsis descendue sur `.bc-label-text` et le crayon en `flex: 0 0 auto` -- **il n'est jamais pousse hors ecran**, c'est ce qui rend le renommage atteignable au doigt ; (c) **sous 768 px, dans `#acquisitionList` seulement**, badges et crumb racine masques -> **53 px -> 191 px de libelle** (x3,6) et le crayon passe a 28 px sans survol. Rien n'est perdu : l'etat du dossier est sur la barre de pipeline **pinnee juste en dessous** (verifie : « A prospecter · Estimation… · Mandat signe · Vendu ») et la fleche de retour fait deja ce que faisait le crumb racine. 🟥 **`display: none !important` obligatoire sur le crumb racine** : `breadcrumb-nav.js` pilote la visibilite des crumbs en style **INLINE** (`.show()`/`.hide()` jQuery) -- meme raison et meme remede que `.bc-distant` du mode deep, juste au-dessus dans le meme fichier. ⚠ **Piege de mesure rencontre** : un premier releve donnait « badges masques = 179 px » (faux) parce que `querySelector('.bc-label-prefix')` prend le prefix du **crumb RACINE**, pas celui du lead - chaque crumb a le sien ; mesures refaites une variante a la fois, avec restauration entre chaque. **Verifie** : parcours mobile complet dans une iframe de 390 px (crayon -> dialog de 256 px qui tient dans l'ecran -> renommage reel **200** -> libelle a jour et entierement visible -> retour au nom par defaut), rendu bureau inchange (crumb racine, badges, crayon a 22 px opacite 0,55), **non-regression d'un AUTRE menu en mobile** (fiche annonce `filterList` : crumb racine visible, 0 crayon, label toujours en `block`), harness **19/19**, E2E HTTP **15/15**, PHPUnit **157 / 3 preexistants**, base rendue a l'identique (0 titre residuel), 0 erreur console imputable (seul l'artefact connu `map is not defined` de l'onglet d'arriere-plan).
- **« Partagé par X » : du chip du header vers le bouton d'assignation, badge compact en liste/kanban (2026-09-21)** : signalé - « le chip prend trop de place dans la liste/kanban, ça ne rentre pas, et dans la fiche ça décale ». **Deux défauts mesurés avant de coder.** **(1) Fiche lead** : `.ld-header-labels` est borné à `max-width: 260px` avec `flex-wrap`, et le chip `lead-shared-by-chip` faisait **240 px en `white-space: nowrap`** → il occupait une ligne entière et poussait les boutons étoile et assignation à la ligne suivante : `.ld-pipeline-row` (barre **pinnée**, hors scroll, visible en permanence) passait de **47 px à 61 px** sur toutes les fiches partagées. **(2) Liste et kanban** : 🟥 le badge n'était pas seulement large, **il débordait de son cadre** - `.acquisition-card-v2 .acq-flag` (0,2,0) et `.kanban-card .kanban-card-header .kanban-flag` (0,3,0) imposent une boîte **carrée** (16 / 18 px), alors que `.kanban-flag-shared` était déclaré **à la racine** (0,1,0) sans `width` : mesuré boîte de 16 px, libellé « Presta C. » de 24 px **débordant de 8 px à droite** par-dessus les flags voisins, et ligne de flags à 92 px pour 100 px de contenu. **Livré** : (a) badge **icône + initiales** (`PC`) à la place du nom tronqué à 14 caractères - **38 px contre ~85**, 0 débordement, le nom complet et la date restant dans le `title` ; (b) chip retiré du header, remplacé par une **pastille ambre en overlay** sur le bouton d'assignation (0 px de largeur en plus, `.ld-pipeline-row` **revenue à 47 px**) et par un **bandeau « Partagé par X · le JJ/MM/AAAA » en tête de son popover** ; (c) helper unique **`lead_user_brief($userId, $known = null)`** (mémoïsé par process) qui produit `name` / `short` / `initials` pour `lead_shared_by` **et** pour le propriétaire - les trois surfaces nomment la même personne de la même façon. ⚠ **Le badge de suivi `.kanban-flag-tracking` portait EXACTEMENT le même défaut** (déclaré à la racine, libellé jusqu'à 24 caractères) : il partage désormais la règle et passe de 18 px débordants à 83 px. ⚠ **`.kanban-card .kanban-flag` ne suffit PAS comme sélecteur** : à spécificité égale (0,3,0) c'est la règle déclarée **plus bas dans le fichier** qui gagne - le conteneur `.kanban-card-header` est indispensable (constaté : le badge kanban restait à 18 px alors que celui de la liste passait bien à 38). **🟥 Défaut préexistant traité au passage** : les deux écritures d'assignation (`assignLead`, `attachAssignee`) filtrent sur `user_id = auth()->id()` et répondent **404** à un non-propriétaire, alors que le bouton et le popover étaient pleinement interactifs - mesuré sur la vraie route : lead partagé → **404 « Lead non trouvé »**, lead possédé → 200. Un viewer qui cliquait « Appliquer » recevait donc « Erreur ». Le popover passe en **lecture seule** pour lui (`data-can-assign="0"`) : bandeau de partage, équipiers **du dossier** (propriétaire en tête, rendus côté serveur dans `data-people` → **aucun appel réseau**, et `/acquisition/team-members` renverrait de toute façon MON équipe), mention « Seul X peut modifier les assignations », ni case à cocher ni bouton. ⚠ Ce mode ne dépend pas de `planaccess:ACCESS_TEAM` (aucune requête) : il fonctionne même sans cette variable de plan. ⚠ Le **re-partage reste owner-only** (décision produit du 2026-06-05), non modifié. **Vérifié** : harness A/B `.insights/shared-chip-to-popover-2026-09-21.php` - **legacy 18/18** (fichiers de HEAD restaurés : chip rendu, nom complet dans le header, badge = nom court) puis **fixed 32/32** ; navigateur sur le lead 648 (owner Presta Clim, partagé avec le lecteur) : chip absent, `.ld-header-labels` 260×60 → 72×30, barre pinnée 61 → **47 px**, pastille 15 px dans le bouton, popover **263×193 px** ouvert **sans une seule requête**, kanban et liste à 38 px avec **0 débordement** ; non-régression sur un lead possédé (popover d'assignation normal, 4 membres, 2 cochés, `team-members` appelé, aucun bandeau) et **application réelle d'une sélection identique → 200 avec `is_primary`, `assigned_by_user_id` et `assigned_at` strictement inchangés en base** ; PHPUnit **157 / 3 échecs préexistants** ; 0 erreur console, 0 ligne de log. ⚠ **Piège de mesure** : le popover affichait d'abord **569 px de haut pour 2 personnes** - les dimensions d'avatar du popover sont scopées sous `.lead-assign-option`, une `<img>` posée dans une autre classe s'affiche donc à sa **taille native**. ⚠ **2e passe (retour utilisateur « on ne voit pas bien »)** : la pastille exige **`z-index: 11`**. Les avatars de `.lead-assignees-stack` portent un z-index **INLINE** de 10 à 8 (`style="z-index: {{ 10 - $idx }}"`) ; la pastille étant le **PREMIER enfant** du bouton, sans z-index elle passe **derrière** eux malgré l'ordre du DOM - mesuré : **9 × 12 px masqués sur 15 × 15**, soit plus de la moitié, et l'icône `fa-share-alt` **entièrement cachée** (la pastille se réduisait à un croissant orange). ⚠ **`document.elementFromPoint` ne peut PAS mesurer ce recouvrement** : la pastille est en `pointer-events: none` (pour que le clic atteigne le bouton), elle est donc invisible à cette API - le verdict se lit sur le **recouvrement géométrique** des rectangles et sur une **capture** avant/après. ⚠ **3e passe (retour utilisateur « je n'aime pas du tout » sur les cartes)** : le badge « ⤳ PC » de la liste et du kanban est **remplacé par l'AVATAR RÉEL du partageur**, posé dans le coin de la vignette (anneau ambre = couleur du partage, bord blanc, repli initiales pour un compte sans photo - un utilisateur du parc est dans ce cas). Partial unique **`acquisition/partials/_shared_by_avatar.blade.php`** (`$context` = `thumb` | `flag`) : les deux cartes ne peuvent pas diverger. ⚠ **La vignette kanban n'est rendue QUE s'il y a une image**, contrairement à celle de la liste (qui a toujours un placeholder) : mesuré **105 leads sur 705 (15 %)** sans vignette possible - acheteur/locataire sans photo, ou lead sans parcelle (pas de SVG). Pour eux l'avatar tombe **dans la ligne de flags** (`--flag`, 20 px), même rendu. ⚠ **`.kanban-card-thumbnail` n'avait PAS `position: relative`** (`.acq-thumbnail` si) : sans lui l'avatar se serait ancré sur la carte entière. Et les deux vignettes portent `overflow: hidden` → l'avatar est posé **à l'intérieur** de leurs bords (`right/bottom: 3px`), jamais en débord, sinon il serait rogné. **🟥 Défaut préexistant corrigé au passage** : **`refreshLeadCard` ne passait pas `viewerUserId`** aux deux vues → `lead_shared_by()` rendait `null` et le badge de partage **disparaissait à chaque rafraîchissement de carte** (retour sur le kanban, édition de lead - `refreshLeadInView`), jusqu'au prochain chargement complet. Vrai depuis l'origine du badge (2026-09-17) ; sans ce correctif, l'avatar se serait effacé en permanence. ⚠ `parcel/card_leads.blade.php` (onglet Leads de la fiche parcelle) ne passe pas non plus `viewerUserId` - **constaté, non traité** (hors périmètre, aucune régression : ce badge n'y a jamais été rendu). La règle SCSS `width: auto` ne sert plus qu'à **`.kanban-flag-tracking`** (qui restait cassé et est réparé). ⚠ **4e passe (retour utilisateur)** : dans le panneau de consultation, le **propriétaire** tombait sur ses initiales alors qu'il a une photo - il n'est pas dans `$assignees`, son avatar ne pouvait donc pas venir du helper local `$avatarFor` (qui travaille sur un modèle User) mais de **`lead_user_brief()`**, qui le résout depuis le 2026-09-21. `data-people` porte désormais `avatar` pour tout le monde ; les initiales restent le **repli** (vérifié : 22 × 22 px, hauteur de ligne identique à un avatar, donc aucun décalage quand les deux coexistent). **🟥 5e passe, défaut préexistant (signalé)** : « j'ouvre le popover, je clique **à l'intérieur**, puis dehors : il ne se ferme plus ». La fermeture au clic extérieur était posée avec **`$(document).one('click.leadAssignClose', ...)`** - `.one()` se détache au **PREMIER** clic capté, **y compris un clic interne** où le handler ne ferme rien : le panneau devenait alors impossible à fermer autrement qu'en recliquant sur le bouton. Présent dans `HEAD` depuis l'origine du popover : il touchait donc surtout le flux d'assignation, où **cocher un équipier est un clic interne** - le premier geste de l'utilisateur consommait le handler. Corrigé par un handler **persistant** détaché explicitement à la fermeture (`closePopover()`, appelée aussi par « Appliquer »), un `off` préalable à l'ouverture (pas d'accumulation), et **Échap** qui ferme également. **Vérifié** : défaut **reproduit** avec le `.one()` d'origine (clic interne puis externe → toujours ouvert), puis sur les deux popovers réels - clic interne (et 4 d'affilée) → reste ouvert, clic externe → se ferme, **0 handler résiduel** ; cocher une case puis cliquer dehors ferme **sans appliquer** (base inchangée) ; 3 ouvertures successives → **1 seul** handler et **1 seul** popover ; Échap ferme. **Vérifié** : harness porté à **51/51**, dont le repli exercé en **fabriquant puis supprimant** le partage sur un lead acheteur sans parcelle (aucun n'existait en base), et **« 30 cartes = 0 requête supplémentaire »** (l'avatar est chargé par `with('profile.avatar.item')` et mémoïsé : 1 à 3 partageurs distincts par écran) ; navigateur : kanban avatar 22 px dans une vignette de 52 (entièrement contenu), liste 26 px dans 80, repli 20 px dans la ligne de flags sans débordement, image réellement chargée, `title` complet, **0 badge résiduel** ; PHPUnit **157 / 3 préexistants** ; base rendue à l'identique (6 lignes `lead_assignees`). **0 route, 0 migration, 0 fichier de package** → aucun tag, aucun `composer update`, aucun `queue:restart` : `git pull` + **`npm run prod`** + `view:clear`.
- **🟥 Le rafraîchissement temps réel de la fiche lead n'avait jamais fonctionné (2026-09-21)** : signalé « quand on signe un document électroniquement, si on était déjà sur la page du lead, il faut recharger la page ». `Acquisition.refreshLeadDetailFromNotification` (`acquisition.js`, appelé via `call_func` des notifications `lead.portal.*`) faisait `showDetail(id, /*fromUrl*/ true)` — or la garde d'idempotence de `showDetail` sort en `return` quand `fromUrl && la fiche de ce lead est déjà ouverte`, c'est-à-dire **exactement** la seule situation où la fonction est appelée. **0 requête émise**, mesuré dans le navigateur sur le bundle servi. Valable pour TOUS les événements portail, pas seulement la signature. Remplacé par un rafraîchissement **ciblé** (`_applyLeadRealtimeRefresh` : fragments + timeline + carte de liste, plus l'onglet GED ou les contacts selon `_notificationExtraZones`), avec garde `_leadDetailIsBusy()` (modale ouverte ou curseur dans un champ → différé par `_deferLeadRealtimeRefresh`, réessai 1,5 s, échéance en temps réel 120 s) pour ne pas écraser une saisie en cours. ⚠ **Le 2ᵉ paramètre de `showDetail` est `fromUrl`, pas un « mode silencieux »** : ne jamais le passer à `true` pour forcer un rafraîchissement. Côté serveur, 3 des 4 chemins de signature n'émettaient aucune notification : `App\Services\LeadRealtimeNotifier::notifyLeadOwner()` branche la **signature multi-parties** et la **signature manuelle** (acteur exclu — il a déjà son écran à jour) ; Yousign volontairement non branché. Un **bloc mort** de `signProposal` (notification construite avec un tableau au lieu de 5 arguments → `TypeError` avalé, journalisé `signProposal notify pro failed`) a été supprimé : `notifyAgent` fait déjà le travail, le rétablir aurait doublé cloche et e-mail. Harness : `.insights/signature-realtime-refresh-2026-09-21.php` (16/16). Déploiement : `git pull` + **`npm run prod`** + `view:clear` — **0 migration, 0 route, 0 fichier de package, donc aucun tag ni `composer update` ni `queue:restart`**.
- **🟥 Signature électronique : la carte du mandat restait « En attente de signature » dans l'onglet Pièces malgré la notification (2026-09-22)** : signalé sur un lead vendeur, mandat généré depuis la fiche puis signé depuis l'espace client d'un second compte - la cloche arrive, la timeline et la GED se mettent à jour, mais `.lead-section[data-section="mandats"]` affiche toujours le mandat non signé. **Cause** : le rafraîchissement ciblé du 2026-09-21 (`_applyLeadRealtimeRefresh`) ne connaissait que deux zones supplémentaires, `documents` (onglet GED, qui ne liste que les FICHIERS) et `contacts` - or **l'état de signature d'un document n'est affiché que par sa CARTE dans l'onglet Pièces** (badge « Signé électronique », date, actions), et aucun fragment ne rendait ces sections : elles ne sortaient que du rendu complet de `lead_detail`. Le défaut valait pour **tous** les documents signés (mandat, compromis, bail, caution, EDL, autres documents légaux) et aussi pour la **signature manuelle** validée depuis la fiche (`refreshLeadAfterDocumentChange` passe par le même chemin). **Livré** : fragment `pieces_html` de `GET /acquisition/lead/{id}/fragments` (`AcquisitionController::renderLeadPiecesHtml`, table `PIECES_SECTION_PARTIALS` : `mandats`, `transactions`, `caution`, `etat_lieux`, `other_legal_docs` → section => html, clé = `data-section` de la racine du partial ; un partial en échec est journalisé et n'empêche pas les autres) + zone `pieces` dans `_notificationExtraZones.document_signed` et `Acquisition._refreshLeadPiecesSections()` qui remplace **seulement le contenu** de chaque section dans `.ld-accordion[data-accordion="pieces"]` (l'item d'accordéon et son état ouvert/fermé sont conservés, `syncAccordionCounts()` recalcule les pastilles). ⚠ Les partials à `<script>` inline (`_lead_offers_timeline`, `_lead_applications_timeline`, `_lead_required_docs`, `_lead_dossier_locataire`) sont **volontairement hors** du fragment : ils ne portent pas d'état de signature et un `replaceWith` ré-exécuterait leurs handlers. **Vérifié** sur le lead 2195 (mandat 587 remis en attente en base, fiche ouverte, statut rétabli, puis notification rejouée) : (a) par `window.refreshLeadDetail(...)` tel que l'appelle `core/auth.js`, (b) par une **vraie notification Pusher** émise depuis le serveur sur le seul canal broadcast (`.insights/replay-signed-notification-pusher-2026-09-22.php`, `Notification::sendNow($user, $notif, ['broadcast'])` - une sous-classe anonyme n'est pas sérialisable pour la file) → carte passée à « Signé électronique » sans rechargement, accordéon resté sur l'item ouvert, nœud `#acquisitionDetail` conservé, 0 spinner, fragments en 200, 0 erreur console, 0 ligne de log, PHPUnit 160 / 3 préexistants. Déploiement : `git pull` + **`npm run prod`** + `view:clear` - **0 migration, 0 route, 0 fichier de package**.
- **🟥 « Inviter un garant » renvoyait 422 sur un lead BAILLEUR, sur un bouton pourtant visible (2026-09-21)** : signale « sur un lead bailleur, onglet Pieces, `ld-acc-item` « Caution & garant », le bouton `invite-guarantor-trigger` existe mais valider la modale donne `{"error":"Lead non locataire"}` ; faut-il masquer le bouton ? ». **Non : c'est la garde serveur qui etait trop etroite.** `AcquisitionController::inviteGuarantor` exigeait `REQUIREMENT_TENANT` alors que la section est rendue pour **LESSOR ET TENANT** (`lead_detail.blade.php:408` + `_lead_caution.blade.php`), comme toutes les sections locatives (Baux, Etats des lieux). **Le 2e bouton de la meme section** (« Acte de cautionnement », `generateActeCautionnement`) n'a **aucune** garde de type et traite deja explicitement le bailleur (`$isTenantLead ? $lead->contact : $lead->contactBuyer`) : masquer aurait laisse produire l'acte d'un garant qu'on ne peut pas inviter. **Metier** : quand une agence met un bien en location pour un bailleur (cas nominal), le garant est celui du candidat locataire et il est rattache au dossier bailleur. **Mesure avant de coder** : le SEUL garant rattache a un lead locatif en base est sur un lead **bailleur** (lead 624, role `guarantor`), **zero** sur un locataire, et **zero token portail `guarantor`** n'avait jamais ete cree - la fonctionnalite n'avait jamais servi. Rien dans la methode n'est specifique au locataire : preset `guarantor` = permissions fixes (documents seuls, `guarantor_only`), template d'invitation #64 a `requirement_type = NULL` (applicable a tous les types). **Livre** : garde alignee LESSOR+TENANT, refus des types non locatifs avec `error: 'lead_not_rental'` + `message` lisible, et cote modale `toast(xhr)` remplace par l'extraction du message (le helper du core privilegie `responseText`, d'ou le JSON brut a l'ecran). ⚠ Docblock corrige : **aucun SMS n'est emis** par cet endpoint, seul `SendModelByMail` est dispatche. ⚠ `Client_portal_token::isGuarantorView()` n'est appelee **nulle part** : la « vue limitee garant » n'existe pas en tant que telle, le portail s'appuie sur les permissions granulaires (constate, non traite). **Verifie** : harness A/B en transaction annulee (`.insights/invite-guarantor-lessor-2026-09-21.sh`) - **legacy 20/20** (le 422 reproduit sur le lead bailleur 526), **fixed 30/30** (bailleur 200 avec lead_contact + token + e-mail prepare + activite ; locataire non regresse ; acheteur et vendeur toujours refuses), base rendue a l'identique sur 6 compteurs ; **E2E navigateur** sur le parcours reel (clic du bouton, modale, validation) : **200**, puis chemin de refus verifie (toast « Ce dossier n'est pas un dossier de location... », 0 JSON brut) ; PHPUnit **157 / 3 echecs preexistants** ; donnees de test supprimees. ⚠ **Aucun e-mail reel ne part en dev** : Mailgun est en domaine **sandbox** (403 « Sandbox subdomains are for test purposes only » dans le log d'administration). **Suite le meme jour (2e retour)** : « quand je valide, toute l'application se rafraichit, a tous les coups » - le callback de succes faisait `setTimeout(location.reload, 800)`. Remplace par `window.Acquisition.showDetail(leadId)`, le pattern deja utilise par les partials voisins de cette fiche (`_lead_dossier_locataire`, `_lead_offers_timeline`, `_lead_applications_timeline`, tous en `showDetail(...) else location.reload()`) : seul le fragment du lead est recharge, et `initDetailTabs` / `initPiecesAccordion` **rouvrent l'onglet et l'accordeon memorises** (`localStorage`, cles `lead_detail_active_tab` et `lead_pieces_acc_open`). ⚠ L'identifiant du lead est releve **AVANT** l'envoi : `onModalHidden` vide `#inviteGuarantorLeadId` des la fermeture de la modale, le lire apres donnerait une chaine vide et retomberait sur le repli `location.reload()`. Verifie en contexte REEL (deep-link `/acquisition/526`, pas un fragment injecte) par un marqueur `window.__noReload` qui **survit** a l'operation (donc 0 rechargement), avec onglet Pieces et accordeon « Caution & garant » conserves, garant affiche dans la fiche, menu et carte intacts, 0 erreur console ; donnees de test supprimees. Il ne reste aucun rechargement inconditionnel dans `resources/views/acquisition/` (les autres `location.reload()` du projet vivent hors fiche lead : portail client, widgets, reglages - non traites). **3e retour le meme jour : « dans le ld-acc-item de Caution & garant on ne voit pas le garant une fois ajoute »** - exact, `_lead_caution.blade.php` ne listait que les actes de cautionnement, jamais les garants : une invitation reussie ne changeait rien a l'ecran (le garant n'apparaissait que dans la section Contacts de l'onglet Pilotage). Ajout d'un bloc **« Garants (N) »** en tete de la section : une carte par pivot `role = guarantor` avec nom, type de garant, coordonnees, et surtout l'**etat de son espace client en 5 etats** (`Sans espace`, `Invite`, `Espace consulte` + date de derniere visite, `Lien expire`, `Acces revoque`) - c'est le seul signal qui dise si l'invitation a porte. Actions : copier le lien, ouvrir en apercu conseiller, retirer ; **renvoyer l'invitation** quand il n'y a pas d'espace actif. ⚠ **0 JS et 0 SCSS ecrits** : les handlers `.ld-portal-copy-btn` (client-portal-admin.js) et `.lc-action-remove` (acquisition.js, avec Swal + `showDetail`) sont deja delegues sur `document`, et les classes `lbv-card*` / `lc-category-header` existent globalement. Compteur de l'accordeon = garants + actes. ⚠ Le type de garant n'est PAS sur `lead_contacts` : il vit dans `client_portal_tokens.metadata.guarantor_type` - un garant rattache par la generation de l'acte (`ensureLeadContactLinked`) n'en a donc pas. 🟥 **Deux defauts trouves en construisant cet affichage** : (1) **le telephone saisi dans la modale etait perdu** - `Contact::firstOrCreate(['phone' => ...])`, or `phone` est un accesseur en LECTURE SEULE vers `address_phone` (seule colonne fillable) : l'attribut etait ignore en silence a chaque invitation. Corrige, avec complement d'un contact connu au numero vide, sans jamais ecraser un numero existant ; enjeu au-dela de l'affichage, `requires_2fa` du token se calcule sur la presence e-mail **ou** telephone. (2) **le type de garant affiche etait celui de la PREMIERE invitation** apres une reinvitation : `clientPortalTokens()` porte deja `orderBy('created_at','desc')`, et un `orderBy('id')` ajoute ne le REMPLACE pas (il departage) - la requete rendait `170,169` et `keyBy` gardait donc le token le plus ancien ; tri fait en PHP (`->get()->sortBy('id')`), insensible aux clauses de la relation. **Verifie** : rendu serveur des 5 etats sur des leads REELS (581 avec espace, 624 sans espace, 526 vide) dont 3 etats obtenus par mutation temporaire du token 168, **restaure a l'identique** ; E2E navigateur du parcours complet (invitation -> la carte apparait sans rechargement, badge « Invite », lien `/client/...` du token le PLUS RECENT, type et telephone corrects ; bouton Retirer -> Swal, carte retiree, pastille masquee) ; PHPUnit **157 / 3 preexistants** ; donnees de test supprimees, les 4 garants reels et le lead 583 de l'utilisateur intacts. **4e retour : l'espace du garant remplacait l'espace CLIENT** - « j'ajoute un garant et la carte `ld-cockpit-card ld-portal-card` de l'onglet Pilotage ouvre un portail *Espace Garant* alors que je suis sur un lead bailleur ». La carte prenait le **dernier token actif** du lead sans regarder son preset, et un espace garant est toujours cree apres celui du client. **Le symptome visible etait le moins grave** : la meme selection non filtree existait dans les **4 observers** (`UserDocument`, `LeadProposedMatch`, `Lead`, `AgendaEvent`), qui dispatchent `NotifyClientPortalEvent` - les notifications « nouveau bien propose », « document ajoute » et agenda partaient donc **au garant**, dont l'espace ne montre que des documents - ainsi que dans le Hero NBA et dans les deux lookups de lien de signature (`AcquisitionController` l. ~9294 et le `vendorToken`). Regle centralisee dans le package (`Client_portal_token::scopeForMainClient()`, cf. `core-city-user/CLAUDE.md`) et appliquee a ces 8 call-sites ; la liste en dur `['seller','buyer','lessor','tenant']` devient `MAIN_CLIENT_PRESETS`. La carte filtre via **`isGuarantorView()`**, methode du modele qui n'etait appelee nulle part jusqu'ici. ⚠ **Non filtres a dessein** : `ClientPortalController::index` (il LISTE tous les espaces du lead, garant compris), le transfert de propriete des tokens d'une agence, et `VisitFeedbackEmailCommand` (deja scope par `contact_id` de l'acheteur). ⚠ Consequence assumee et correcte : sur un lead dont **seul** le garant a un espace, la carte repasse a « Inactif » avec le bouton de creation - le client n'en a effectivement aucun. **Verifie** : harness 11/11 en transaction annulee (defaut reproduit, puis scope, carte, **observer reel par `Bus::fake`**, pipeline, acces par id preserve), A/B HTTP sur le lead 581 (« ● Actif » vers l'espace garant -> « ○ Inactif », lien du garant conserve dans sa propre carte), rendu navigateur des deux onglets, 0 erreur console, PHPUnit 157 / 3 preexistants. **Deploiement** : **tag core-city-user + `composer update` sur rs_infoparcelle_application ET administration + `queue:restart`** (les observers tournent dans les workers), puis `git pull` + `view:clear`. **0 migration, 0 route, 0 build.** **5e retour : le portail bailleur listait le bailleur et le garant comme « candidats »** - `HasClientPortal::buildKeyData` lisait `leadContacts()` sans filtre de role (7 emplacements avec les compteurs du hero, du parcours et des KPI). Le client voyait **son propre nom** en candidat, et l'etape « Sélection candidats » etait franchie en permanence (le bailleur etant toujours rattache). Mesure : **18 contacts sur 21** affiches a tort sur 13 leads bailleurs. Correctif dans le package (`Lead_contact::TENANT_CANDIDATE_ROLES` + `tenantCandidatesQuery()`, cf. `core-city-user/CLAUDE.md`) ; harness lecture seule 15/15 sur 3 portails reels, A/B HTTP, rendu navigateur, PHPUnit 157 / 3 preexistants. ⚠ **Piege d'environnement qui a fait croire a un correctif inoperant** : `docker cp` **preserve le mtime**, donc opcache (`validate_timestamps=1`, qui compare le mtime) continuait de servir la version compilee - l'A/B rendait deux fois la meme page alors que le meme code appele en tinker donnait le bon resultat. Toujours `docker exec docker-rs-1 touch <cible>` apres un `docker cp` de PHP.
- ⚠ **`advert_locations.advert_flx_id` = cle du BIEN** (`core_advert.properties.flxId`), jamais celle d'une de ses diffusions (2026-09-21). Un bien diffuse sur plusieurs portails a un `propertie_adverts.flxId` PAR portail (61 % des biens sont multi-diffuses) et ~20 % des biens ont un flxId qui n'appartient a AUCUNE de leurs annonces. Toute lecture passe par `Advert_location::resolveAdvertIdsByFlx()` / `resolveThumbsByFlx()` ; toute ecriture pose la cle du bien. Cote JS, source unique = `window.resolveCurrentAdvertFlxId()` (le `data-advert` du bouton favori de la fiche).
  - **Lecteurs par jointure directe (corriges, ils rendaient 0 pour ~20 % des biens)** : `TabAdvertController::load`, `ParcelNoteController`, `AcquisitionController::loadLocatedAdverts`, `_portal_offers_made`, plus `HasClientPortal` x2 / `HasLeadHub` / `PdfLeadsExport` cote packages.
  - **Lecteurs par `advert_search_index.flx_id` (deja la cle du bien, repares par la seule migration)** : dashboard (`HasDashboardView`), exclusion des annonces similaires (`TabAdvertController::matching`), **couche carte « Annonces localisees » (`core-city/OverlayerController`)**, **badge « deja localisee » de la liste des annonces (`AdvertController`)**, **badge de l'onglet Matching (`AcquisitionController:5900`)**, prefill (`:12937`), prix des RDV (`AgendaController`, `HasLeadHub`), autorisation d'apercu au portail (`ClientPortalPublicController`), `PdfParcelsSheet`, `HandlesMatchSelection`, `advertStatus` / `advertLocatedBounds`.
  - `Lead_proposed_match.match_advert_flx_id` porte DEJA la cle du bien (`data-match-id` = `advert_search_index.flx_id`, mesure 4/4) : les comparaisons croisees avec `advert_flx_id` sont donc coherentes depuis la conversion.
  - ⚠ `GET /acquisition/advertStatus/{flxId}` n'a **aucun appelant** (route dormante, constatee le 2026-09-21, non traitee).
- **Top 10 de la fiche annonce : les parcelles DEJA LIEES passent devant** (2026-09-21). Le top 10 est le resultat d'un scoring sur la zone courante ; une parcelle liee qui n'y figurait pas devenait introuvable en revenant sur la fiche. `GET /acquisition/advert/located-parcels/{flxId}` rend leurs cartes avec le MEME constructeur de requete et la MEME vue que la recherche, l'appel part en parallele du top 10, et l'assemblage dedoublonne par `data-code`. ⚠ Les compteurs du bloc restent ceux du scoring : les parcelles liees s'ajoutent en tete sans etre comptees, sinon « Top 12 parcelles ... sur 8 resultats ».
  - ⚠ Le bloc est rendu en style **`list`** (vignette a gauche), jamais `thumb` : sinon la carte liee sort de la grille.
  - 🟥 Le rendu etant DIFFERE (il attend les parcelles liees), tout binding direct sur `.resultListItem` doit etre rejoue apres : `injectLocateTop10` et le handler `click.quickparcel` (selection sur la carte + classe `active`) s'executaient sur un bloc vide.
  - A l'arrivee sur la fiche, la parcelle liee est aussi **selectionnee** sur la carte (contours orange) : `localise({layer:'parcel', ...}, map, false)` apres le fitBounds. ⚠ **Sans `localiseSearchDropdown`** (le fitBounds cadre deja l'ensemble des liees) et **seulement si `isShowMap()`** - `localise` fait `showMap(0)` en mobile, ce qui ejecterait la fiche au profit de la carte. Recette carto : `.insights/advert-located-parcel-selection-2026-09-21.py` (Chrome headless jetable + SwiftShader + CDP, la carte Google Maps ne s'initialisant pas en onglet d'arriere-plan).
- **PDF de la sélection Matching : pages « Caractéristiques » étoffées et plan de parcelle à la place du « 404 » (2026-09-17, suite)** : retour utilisateur sur un PDF réel - « les pages de caractéristiques de chaque bien sont trop light (8, 10, 13), et la page d'intro d'un bien sans photo a le fond 404 : mettre le SVG de la parcelle avec les voisines ». **Mesuré avant de coder** : les critères d'estimation sont presque vides en pratique (sur les biens de test, seuls `piscine`, `cave`, `standing`, chambres, année) ; la richesse est dans le CADASTRE (`fr_parcels.superficie` + `fr_parcel_details` : jardin, emprise au sol, niveaux, piscine en m², dépendances, mitoyenneté, année, type de bâti, surface habitable calculée) - y compris pour un bien SANS estimation. **Livré côté site** (`BuildsAdvertPdf`) : `leadParcelFactsForPdf()` (1 requête `pgsql_city`, parcelles multiples agrégées), `leadFactSectionsForPdf()` (« Le bâti », « Le terrain », « Confort » - sans répéter type / surface / pièces / chambres / année déjà portés par les tuiles), `leadHighlightsForPdf()` (« Les atouts du bien », **déduits de faits vérifiables uniquement** : piscine de N m², jardin, terrain ≥ 500 m², plain-pied, sans mitoyenneté, construction récente, ≥ 4 chambres, DPE A à C, dépendances ; jamais d'argument inventé, un bien à rénover n'est pas un atout) ; repli du type (« Immeuble » au lieu de « Bien »), de la surface (« 377 m² environ (cadastre) », drapeau `surfaceEstimated`), de l'année, du terrain, de `hasPool` / `hasGarden`. Nouvelles clés optionnelles du tableau mappé : `parcelCadastreIds` (consommée par le job pour dessiner le plan, **jamais rendue**), `factSections`, `highlights`, `surfaceEstimated`. En comparateur avec fiches détaillées, les annonces sont désormais mappées avec leur description. Le plan et la mise en page vivent dans core-advert (cf. son CLAUDE.md). ⚠ Le document ne porte toujours NI adresse NI référence cadastrale ; le plan est rendu sans numéro de parcelle ni mesure, avec la mention « Localisation précise communiquée lors de la visite » - il reste qu'un plan permet à un lecteur motivé de retrouver le bien : **décision assumée par l'utilisateur**. Script de rendu réel pour relecture visuelle : `.insights/match-selection-pdf-render-2026-09-17.sh <lead> <user> <dossier>` (vraie route + workers, puis document / activité / statut rendus à l'identique). Lead de test conservé : **2143** (compte démo CRM 214295), 3 épingles = sans annonce ni photo (867), annonce liée (2126), photos sans annonce (866). Vérifié : rendu réel relu page par page (avant / après), E2E 37/37, 0 job en échec, 0 erreur de log.
- **Avis de valeur PDF : bloc honoraires gaté par `CRM_FEES` + case « Afficher les honoraires » (2026-09-17, suite du lot honoraires)** : demandé « sur la page Expertise de la valeur du bien, ne pas afficher le bloc honoraires sans abonnement avec `CRM_FEES`, et ajouter dans `estimation-type-selector` une case visible seulement avec `CRM_FEES` pour l'afficher ou non ». **Avant** : le bloc `.av-fees-card` de `page_evaluer_valeur` s'affichait pour TOUT le monde (un Premium ou un Basique y voyait le barème par défaut), le plan n'étant jamais consulté. **Côté package (core-city)** : `PdfParcelsAvis::feesAllowedByPlan()` = `hasPlanVariableForProject($this->site, 'ACCESS_CRM')` ET `'CRM_FEES'`, passé à la vue en `fees_allowed`. 🟥 **Le périmètre est passé EXPLICITEMENT** : ce PDF est rendu par le worker de queue d'/administration, sans `auth()` ni `APP_ID` - `plan_access()` ou `hasPlanVariable()` y seraient fail-open, donc « autorisé » pour tout le monde (même piège que le branding du 2026-08-14). Fail-open conservé pour les projets qui ne déclarent pas la variable. `PdfParcelsAvis::feesShownByUserSetting()` lit la préférence `hasSetting($this->site->project_id, 'estimation_show_fees', '1')` (⚠ `project_id`, jamais `APP_ID`) → `fees_show_option`. La vue affiche le bloc si `($fees_allowed ?? true) && ($fees_show_option ?? true)`. **Côté site** : l'option est une **PRÉFÉRENCE DE COMPTE** - `core.user_settings`, clé **`estimation_show_fees`** (`'1'` / `'0'`, écrite par `updateUserSetting` sur le `project_id` du site) : **cochée par défaut** tant qu'elle n'a jamais été enregistrée, et le choix de l'agent est retrouvé à la réouverture de N'IMPORTE QUELLE fiche (demande utilisateur du 2026-09-17 ; une première version la rangeait par bien dans `parcel_user_details`, abandonnée le jour même). Route `POST /estimate/show-fees` → `EstimationController::saveShowFees` (`planaccess:ACCESS_CRM` + `planaccess:CRM_FEES`, aucun identifiant de parcelle). ⚠ **Changer l'option invalide TOUS les avis déjà générés du compte** (`estimations.is_pdf_generated = false`, y compris les estimations des leads PARTAGÉS, qui appartiennent au propriétaire du lead) : sinon le bouton PDF de la fiche lead, qui ressert un PDF « à jour », aurait redonné l'ancien fichier avec ses honoraires. Helper `estimation_show_fees_option($user = null)` (même lecture que le PDF, `project_id` résolu une fois). 🟥 **Interrupteur sans couleur à l'état actif** (signalé) : la teinte vient de la variable `--switch-checked-bg` posée par `.bg-to-switch` (`core::includes/white_mark`), mais la règle qui l'APPLIQUE est limitée à `.criteria-switch` (`_criteria-modern.scss`) - tout interrupteur placé ailleurs dans l'onglet reste gris. Règle dédiée `.estimation-type-selector .estimation-fees-option` dans `_tab-estim.scss` (piste grise décochée, couleur du thème cochée, curseur). Carte `.estimation-type-selector` de `card_estimate_unified_modern` : case `#estimationShowFees` rendue seulement si `fees_feature_available()`, `data-has-fees-option`. 🟥 **Le verrou Vente/Location change de forme** : lié à un lead vendeur ou bailleur, la carte était masquée EN ENTIER ; quand elle porte l'option, seul `.estimation-type-switch-wrapper` est masqué et la carte reste (c'est justement sur un lead que les honoraires comptent) - rendu serveur et miroir JS `setEstimationTypeLock(locked)` (`tab-estim.js`, remplace les 2 `addClass/removeClass('d-none')` sur la carte). Sans l'option, comportement d'origine strictement conservé. Handler délégué `change.estShowFees` sur `document` (la carte est remplacée à chaque recalcul par `replaceWith`), retour à l'état précédent en cas d'échec, 432 laissé au handler global. **Vérifié** : A/B `.insights/avis-fees-block-2026-09-17.sh legacy|fixed`, **lancé depuis /administration** avec le régime d'erreur du worker et le HTML RÉEL du document (`generateHtml()`, sans Chrome) - legacy 2/2 (fichiers de HEAD restaurés dans le vendor : bloc affiché à un CRM sans `CRM_FEES` et à un Premium, plan jamais consulté) puis fixed 6/6 (option absente / décochée / recochée, `CRM_FEES=0`, Premium, Basique) ; navigateur en session réelle : clic réel sur la case → `POST /estimate/show-fees` 200 dans les deux sens, état relu après rechargement du fragment, carte liée au lead 663 = visible avec le choix Vente/Location masqué, libellé et interrupteur sans chevauchement ; PHPUnit 157 / 3 préexistants ; base rendue à l'identique. ⚠ `app.js` sans hash : hard-reload après le build, sinon le clic n'émet rien (rencontré en recette). ⚠ L'option est **par utilisateur** : sur un lead partagé, c'est celle de l'agent qui génère le PDF qui s'applique. ⚠ **Piège de recette** : dans un onglet Chrome en arrière-plan les transitions CSS sont gelées, `getComputedStyle(label, '::before')` rend donc la couleur de l'état PRÉCÉDENT (piste « bleue » alors que la case est décochée) - neutraliser `transition` avant de mesurer. Déploiement : tag core-city + `composer update` (rs_infoparcelle_application ET administration) + `queue:restart` + `npm run prod` + `view:clear` + `route:clear`.
- **Honoraires gatés par la variable de plan `CRM_FEES` + teaser dans l'onglet Honoraires (2026-09-17)** : signalé « j'ai désactivé les honoraires de l'abonnement CRM pour tester et je les vois toujours ; il faudrait, comme dans l'onglet Apparence, expliquer ce que c'est et dans quel abonnement c'est disponible ». **Cause** : `CRM_FEES` (`core.project_extras` id 98, « Honoraires & barèmes ») était l'un des 9 extras `CRM_*` créés le 2026-07-10 **uniquement pour l'affichage** des cartes tarifaires : 0 lecture dans le code, tout le gating passait par `ACCESS_CRM` - la décocher ne pouvait rien changer. Et sans `ACCESS_CRM`, l'onglet **disparaissait** sans explication. **Livré (100 % site, 0 migration, 0 route, 0 JS, 0 fichier de package)** : règle unique **`fees_feature_available($user = null)`** (`app/Helpers/lead_capability_helpers.php`) = `ACCESS_CRM` ET `CRM_FEES` (via `User::hasPlanVariable`, même mécanisme que `hasLeadCapability` : fail-open si le projet ne déclare pas la variable, mémoïsé par utilisateur) ; `lead_capability('fees')` l'exige en plus → les 12 call-sites (KPI strip, kanban et sommes de colonnes, liste, dashboard KPI, modale prix, quick actions, `calculate_fees`) suivent **sans être modifiés** (mesuré sur la fiche lead 663 : 4 cartes KPI → 3, grille `--lkc-cols` recalée). Onglet : `honoraires` sort du filtre `plan_access('ACCESS_CRM')` de `setting_list.blade.php` (le filtre par mode pro reste : pas d'onglet pour un particulier) ; le panneau rend `setting.tabs.fees` ou un **teaser** `.st-fees-plan-teaser[data-plan-teaser="CRM_FEES"]` (4 cartes, toutes vérifiées contre ce que le produit affiche réellement). Le plan requis est résolu par `BuildsPlanTeaser::planTeaserData('CRM_FEES')` (trait désormais monté sur `SettingController`, données dans `$feesTeaser`) : le nom du plan suit la base. Le coeur de `includes/_plan_teaser` est extrait en **`includes/_plan_teaser_body`** (sans le conteneur de défilement, un onglet de /parametres ayant déjà le sien) ; `_plan_teaser` l'inclut, rendu inchangé pour Agenda et Widgets (vérifié). `SettingController::list` ne charge plus le barème quand la fonctionnalité est fermée (`$feesAvailable`). Routes : `planaccess:CRM_FEES` ajouté au groupe `/parametres/honoraires` et aux 3 écritures `manual-fee` (POST/DELETE) / `recompute-fees` → **432**. ⚠ **Volontairement NON gatés** : le CALCUL du barème (`HasFee::getCalculateFees`, `EstimationController`, `ActionController`) qui alimente l'avis de valeur, fonctionnalité Premium ; `GET /acquisition/lead/{id}/fees` (lecture, consommée par la carte « Prix & honoraires » des modales mandat et bail) ; les variables d'honoraires des mandats et baux (mention légale). `settings-fees.js` est entièrement délégué sous `#fee-container` : inerte sans lui, aucun build. ⚠ **Les 8 autres extras `CRM_*` restent display-only** (les rendre fonctionnels un par un est un chantier à part). ⚠ **Effet de bord à connaître** : mettre `CRM_FEES` à 0 sur un plan retire AUSSI les honoraires de la fiche lead, du kanban et du dashboard des abonnés de ce plan, pas seulement l'onglet. La bascule agit **immédiatement**, sans vider de cache (vérifié). **Vérifié** : harness `.insights/fees-plan-gating-2026-09-17.sh` (un process par état, 16/16 fermé puis 16/16 ouvert, autres capacités CRM inchangées, calcul du barème accessible à un Premium), E2E HTTP en session réelle `.insights/fees-plan-gating-e2e-2026-09-17.sh` **20/20** (CRM avec `CRM_FEES=0` → teaser + 432 ; `=1` → barème + 200 ; Premium → teaser « Réservé aux membres CRM » + 432 ; particulier → pas d'onglet), rendu navigateur (4 cartes en grille 2 colonnes, CTA `/abonnement`, 0 débordement, 0 erreur console), PHPUnit 157 / 3 préexistants, 0 ligne de log. Déploiement : `git pull` + `view:clear` + `route:clear`, puis **vérifier en prod que `CRM_FEES` vaut 1 sur les plans 329 ET 332** (sinon les abonnés CRM perdent leurs honoraires au déploiement).
- **Onglet Matching : 3e canal « PDF » de la modale « Envoyer la sélection / Présenter ce bien » (2026-09-17)** : demande « le bouton Envoyer ce bien fonctionne par e-mail et SMS, ajouter un 3e type par PDF en se basant sur le PDF comparatif d'annonces ». **Constats avant de coder** : le comparateur ne connaît que des annonces (`Propertie_advert.id`, minimum 2) alors que les épingles sont surtout des biens du PORTEFEUILLE (`TYPE_LEAD`) et que « Envoyer ce bien » porte sur 1 bien ; ses pages exposent le site d'origine, les jours en ligne, le nombre de vendeurs et un scoring d'investissement, non présentables à un client. **Livré** : trait **`App\Http\Traits\BuildsAdvertPdf`** (partagé par `ActionController` et `AcquisitionController`) - `getTheme`, `mapAdvertForPdf` et `buildExportFilename` y sont DÉPLACÉS tels quels (protected), plus **`mapLeadForPdf(Lead, photos)`** (mêmes clés que `mapAdvertForPdf`, TOUTES renseignées : une clé absente = warning = ErrorException dans le worker ; commune seulement, jamais l'adresse ni la note de l'agent ; prix et surface de l'estimation seulement si `hasMatchingEstimation()` ; id NÉGATIF pour ne jamais entrer en collision avec un id d'annonce ; exposition normalisée - elle arrive en chaîne, tableau ou JSON), `leadPhotoUrlsForPdf()` et **`dispatchAdvertsPdf($mapped, $opts)`** (1 bien = `PdfAdvertSheet`, 2 et plus = `PdfAdvertsCompare`, puis `AdvertsCompareCompleted` avec `lead_id` / `contact_id` → GED du lead). 🟥 **Aucun job de site** : le trait prépare des tableaux et dispatche les jobs de core-advert, seuls connus des workers d'/administration. Endpoint **`POST /acquisition/lead/{id}/matches/selection/pdf`** (`HandlesMatchSelection::matchSelectionPdf`, `planaccess:ACCESS_CRM` + contrôle `plan_access('ACCESS_PDF_ADVERT_COMPARE')` → 432, `Lead::sharedFor`) : lead acheteur / locataire = les épingles cochées (portefeuille ET annonces, 10 au plus → 422 `too_many`, tri par prix, refId A01...) ; lead vendeur / bailleur = la fiche du bien du lead, document unique, **aucun destinataire joignable requis**. Toujours `client_facing` + `disable_scoring`. Photos d'un bien du portefeuille : `lead_photos`, sinon la vignette déjà montrée sur sa carte (`_thumbnail_url`), jamais `/parcel/svg/` (exige une session). Dédoublonnage par **jeu d'épingles** (`metadata.match_ids_key`) : régénérer la même sélection remplace le document, une autre sélection est conservée. **Décision utilisateur** : un PDF généré VAUT présentation - activité `match_selection_sent` avec `channel = pdf` + `fire('matches_sent')` ; la puce des cartes dit « PDF généré le jj/mm » (`_match_tracking`), et un PDF généré après coup **ne masque pas** un envoi réel par e-mail ou SMS (`matchSelectionSentMap`). **Modale** : 3e carte `lpf-channel[data-channel=pdf]`, volet `data-mss-pane="pdf"` (fiche ou comparatif, paysage dès 5, case « fiche détaillée de chaque bien » à partir de 2), bouton « Générer le PDF », contacts sans coordonnée non bloquants, carte verrouillée si `has_pdf = false` (exposé par l'aperçu avec `pdf_max`) ; en canal PDF aucun aller-retour d'aperçu (rien à composer). Au succès : `DocumentHub.create('compare', 'selection' | 'selection_sheet', task_id)` (2 sous-types ajoutés à `document-hub.js`). ⚠ **Limite assumée** : les PHOTOS d'une annonce du marché peuvent porter le logo ou le panneau de son diffuseur (mention dans la modale). ⚠ Un bien du portefeuille peu renseigné donne des pages pauvres (« Prix sur demande », caractéristiques vides). **Vérifié** : E2E `.insights/match-selection-pdf-e2e-2026-09-17.sh` **35/35** - vraies routes en session, PDF **réellement produits par les workers d'/administration**, texte relu par `pdftotext` (0 adresse exacte, 0 « Jours en ligne » / « Vendeurs » / « Source » / score, comparateur du menu Annonces inchangé), paysage à 5, 404 sur le lead d'autrui, dédoublonnage, état des leads rendu à l'identique ; contrôle VISUEL des pages (`pdftoppm` + `montage`), qui a révélé 3 défauts invisibles aux assertions de texte (bien sans prix affiché « 0 EUR » et élu « meilleur prix », exposition en JSON brut, et 🟥 `DivisionByZeroError` de `buildSheetHtml` pour un bien à UNE seule photo - corrigés, cf. `core-advert/CLAUDE.md`) ; navigateur : états des 3 canaux dans les deux sens, requête réellement émise, DocumentHub, puces ; PHPUnit 157 / 3 préexistants. Déploiement : tag core-advert + `composer update` (site ET administration) + `queue:restart`, puis `git pull` + `npm run prod` + `view:clear` + `route:clear`. ⚠ **Ordre imposé : le package AVANT le site** (le site passe un 10e / 15e argument aux jobs).
- **Portail client, biens sélectionnés : flèches de carrousel, visionneuse plein écran et suivi « bien consulté » (2026-09-17)** : signalé « pas de flèche gauche / droite au survol de l'image, et pas de zoom au clic ». Le carrousel de `_key_data.blade.php` était CSS seul (scroll-snap + points en ancres `#cpc-...`, qui faisaient en plus sauter la page). **Livré** : flèches `.cp-match-carousel-nav` (visibles au survol à la souris, en permanence et plus petites au doigt via `@media (hover: none)`, masquées en bout de course), points en `<button>` avec l'actif surligné, flèches clavier sur la piste, repère « agrandir » `.cp-match-zoom-hint`, et **visionneuse `.cp-lb`** créée une seule fois par `client_portal.js` : précédente / suivante, compteur, Échap, clic hors image, flèches clavier, glissement au doigt, verrouillage du défilement de la page, retour du focus, préchargement de la photo voisine, plein écran sans marge sous 640 px (`env(safe-area-inset-*)`). Une vignette unique est zoomable aussi (jamais le plan cadastral). Le défilement reste **natif** (le glissement au doigt marche sans JS). ⚠ **Handlers délégués sur `document`** : la section key-data est re-rendue par `refreshPortalSection` ; `scroll` ne remontant pas, il est écouté en phase de capture. ⚠ **L'état (point actif, flèches) est appliqué dès la navigation** (`syncCarousel(carousel, idx)` + `data-cp-carousel-index`), sans attendre l'événement `scroll`, qui n'est émis ni avant le début de l'animation ni dans un onglet en arrière-plan. `prefers-reduced-motion` → défilement instantané. **🟥 Défaut trouvé au passage** : aucun code n'appelait `POST /client/{token}/match/{matchId}/click` (`trackMatchClick`) → `seen_at` / `clicked_at` à NULL sur toutes les épingles, donc la puce « Vu le ... » des cartes de l'onglet Matching ne pouvait jamais s'allumer. Le payload de `buildKeyData` (core-city-user) porte désormais **`match_id`** (id de l'épingle), rendu en `data-match-id` ; ouvrir la visionneuse d'un bien poste le suivi une fois par épingle et par page (`postJson`, muet en aperçu conseiller). Vérifié : navigateur (portail du lead 2127 en aperçu conseiller) - flèches, points, bornes, clics rapprochés, visionneuse au bon index, clavier, fermeture, vignette unique ; iframe de 390 px : visionneuse 390 x 800, image contenue, boutons dans l'écran, glissement simulé ; endpoint de suivi 200 (puis restauré) et 404 sur l'épingle d'un autre lead. ⚠ `client_portal.js` et `client_portal.css` n'ont pas de hash : hard-reload, et une iframe de test garde l'ANCIEN cache (y injecter le JS et le CSS frais). ⚠ Constaté, non traité : le portail déborde de 8 px en largeur à 390 px, indépendamment des cartes de biens (préexistant, quelque part dans `.cp-shell`). Déploiement : `npm run prod` + `view:clear` ; tag core-city-user pour `match_id`.
- **Onglet Matching : les actions sur les épinglés refaites - modale « Envoyer la sélection » / « Présenter ce bien » (2026-09-17)** : signalé « le badge de crédits SMS clignote au survol du bouton SMS, et ces boutons sont trop basiques, ils ne servent presque à rien ». **Le clignotement** : `.sms-quota-badge` était rendu DANS le flex `.match-group-header` et révélé par `:has(bouton:hover)` ; en s'affichant il poussait les boutons, le curseur quittait le bouton, le badge disparaissait, et ainsi de suite (son lien « Recharger » était donc incliquable). **Les boutons, eux, étaient cassés, pas seulement pauvres** : (1) 🟥 l'envoi groupé e-mail / SMS postait `match_ids` = `data-match-lead-id` (id du LEAD épinglé) alors que `bulkMatchAction` cherchait des `lead_proposed_matches.id` → **422 « Aucun match valide trouvé » à chaque clic**, 0 activité `bulk_match_sent` en base sur 51 épingles : il n'a jamais fonctionné ; (2) le serveur écrivait toujours au contact du lead ÉPINGLÉ, sens absurde sur un lead acheteur (il fallait envoyer la sélection au contact du lead courant) ; (3) les actions par carte (`openProposalModal`) ignoraient la cible et ouvraient un message libre VIDE, « PDF » = « E-mail » ; (4) la carte annonce n'avait aucun bouton Épingler / Retirer (2 épingles annonce contre 51 lead, barre d'actions morte) ; (5) « Visite » passait un 3e argument que `Agenda.showEventForm` ignore : formulaire vide ; (6) `seen_at` / `clicked_at` / `reaction` jamais montrés. **Livré** : trait **`App\Http\Traits\HandlesMatchSelection`** (monté sur `AcquisitionController`) + 2 routes `planaccess:ACCESS_CRM` : `GET /acquisition/lead/{id}/matches/selection/preview` et `POST .../send`. **Le sens est décidé par le serveur selon `requirement_type`** : acheteur / locataire = UN message au contact du lead listant les biens cochés (portefeuille ET annonces) ; vendeur / bailleur = un message par acheteur épinglé présentant LE bien du lead, avec **épingle réciproque** créée `withoutEvents` sur le lead de l'acheteur (le bien apparaît dans SON espace client, sans la notification automatique en doublon). Contenu composé en code (fiches illustrées, bouton vers l'espace personnel, signature du conseiller), **jamais l'adresse exacte d'un bien du portefeuille dans un message sortant (commune seulement)**, prix de l'estimation seulement si `hasMatchingEstimation()`. **L'aperçu ne crée aucun espace client** : lien rendu par `PROSPECTION_PORTAL_PLACEHOLDER`, prénom par `[prenom]` (sens vendeur, un message par destinataire) ; remplacés à l'envoi (SMS = court-lien de suivi). Le contenu n'est posté que s'il a été modifié, il est assaini (`script|style|iframe|object|embed`, `on*`) et rangé dans les clés `custom_email_subject` / `custom_email_html` / `custom_sms_body` **déjà lues par les jobs du package : aucun job de site**. Le modèle `portal_match_added` (#53 / #134) ne sert que de porteur de la `Lead_proposal`. Un destinataire sans coordonnée est **listé** (`skipped`), jamais sauté en silence ; aucun destinataire joignable → 422 `no_recipient` ; crédits SMS insuffisants → 422 `sms_quota`. `bulkMatchAction` est conservée et **délègue**. `fire('matches_sent')` conservé (lead source, et lead de l'acheteur en sens vendeur). **Suivi sans migration** : l'activité `match_selection_sent` du lead porte `match_ids` → `matchSelectionSentMap()` → puce « Envoyé le jj/mm » ; « Vu » et réaction lus sur l'épingle (côté vendeur : sur l'épingle réciproque de l'acheteur, `matchSelectionReciprocalMap()`), partial `_match_tracking.blade.php`. ⚠ `lead_proposed_matches.note` est AFFICHÉE dans le portail : ne jamais y ranger un état technique. **UI** : les 4 icônes d'en-tête deviennent un bouton libellé `.match-group-send-btn`, **un seul par onglet** (sur un lead acheteur qui a épinglé des biens du portefeuille ET des annonces, il est porté par le groupe du portefeuille, sinon par celui des annonces : la modale regroupe les deux) ; la carte annonce ÉPINGLÉE a la même disposition qu'un bien du portefeuille (prix en `.match-card-meta`, suivi, puis actions à gauche), la barre `.match-card-actions--advert` ne sert plus qu'aux suggestions ; cartes épinglées = « Envoyer ce bien » / « Présenter le bien » (`.match-selection-open[data-proposed-id]`, un seul élément coché) + « Visite » (`.match-visit-open` → `window.openMatchVisit`, préremplissages `pendingAgenda*` : titre, type visite, lead, adresse du bien) ; carte annonce : Épingler / Retirer + mêmes actions. Modale `acquisition/partials/_modal_send_selection.blade.php` (body-level, `lead-modal-modern`, réutilise `lpf-channels` et `slp-preview`), JS `resources/js/match-selection.js` (numéro de séquence : la réponse d'un état quitté est ignorée ; le badge de quota SMS y vit, visible en canal SMS seulement). `_runMatchAction` et les handlers `.match-action*` sont supprimés ; le CSS de survol du badge, devenu sans objet, aussi. ⚠ SMS par défaut : « Découvrez-le ici » et non « À découvrir » - le À majuscule sort de l'alphabet GSM 7 bits et ferait passer tout le SMS en UCS-2. **Notification automatique d'épinglage** (`LeadProposedMatchObserver`) : désormais **différée de 20 min** (`city-user.portal_match_added_delay_minutes`) avec `proposed_match_id` ; le job l'abandonne si l'épingle a été retirée, déjà envoyée par l'agent, ou si une épingle plus récente non envoyée existe (une seule notification par rafale) - cf. `core-city-user/CLAUDE.md`. ⚠ **Décision produit à confirmer** : ce différé change le délai de la notification existante. ⚠ Non traité : pas de sélecteur de modèle dans la modale (le texte par défaut est en code, modifiable à chaque envoi) ; pas de PDF « sélection de biens » (lot C du brainstorm). **Vérifié** : harness A/B `.insights/match-selection-2026-09-17.sh` - **legacy 5/5 avec le contrôleur de HEAD restauré (422 reproduit)**, **fixed 51/51** (deux sens, aperçu sans token, contenu édité assaini, SMS court-lien, non joignable, cloisonnement 404, rendu des cartes, anti-doublon, et **le job SendModelByMail envoie bien ce contenu**, transport array) ; navigateur en session réelle (leads 666 acheteur et 663 vendeur) : requête réellement émise = vrais ids d'épingle, contenu posté seulement après édition, décocher recompose le message, canal SMS + badge + compteur 2 segments, carte seule cochée, Visite préremplie, annonce épinglée / envoyée avec image / retirée, 0 erreur console ; envoi intercepté au niveau XHR (les contacts de démonstration ont de vraies adresses) ; PHPUnit 157 / 3 préexistants ; base rendue à l'identique. Déploiement : `git pull` + `npm run prod` + `view:clear` + `route:clear` ; tag core-city-user + `composer update` (site ET administration) + `queue:restart` pour l'anti-doublon.
- **🟥 Lead cree depuis le menu Annonces absent du matching : il lui manquait `geo_point_2d`, pas l'adresse (2026-09-17)** : le bouton « Lier la parcelle » (`.parcel-locate`) puis « Oui, ajouter aux leads » poste `source=advert` + `cadastre_id` + `adresse_complete` (= titre de section « Annonces immobilières ») sur `POST /acquisition/lead/create` - **sans `geo_lat` / `geo_lng`**, que seule la modale `#modalAddLead` envoie. Le matching exige ce point (cf. `core-city-user/CLAUDE.md`, ou vit le correctif : hook `Lead::saving`). Meme trou sur `.opp-add-lead-btn` (Opportunites) et `locateAdvert` avec `create_lead`. ⚠ `updateRequirement` remet `geo_point_2d` a null quand `geo_lat` / `geo_lng` arrivent vides (modale ouverte sur un lead sans point, adresse non retapee) : c'est desormais rattrape par le hook, **ne pas « corriger » ce null cote controleur**. **Cote site** : `resolveCleanLeadAddress()` rend la meilleure adresse de la parcelle (`Fr_parcel::getAddresses()[0]['label']`, meme source que `leadPrefill`) avant le repli « commune (parcelle REF) », et `locateAdvert` passe aussi par elle. 0 JS modifie : la requete du navigateur est inchangee. Harness `.insights/lead-geo-point-from-parcel-2026-09-17.php` (14/14, transaction annulee), E2E `.insights/lead-geo-point-from-parcel-e2e-2026-09-17.sh` (vraies routes, legacy 2/7 → fixed 7/7, leads supprimes), rattrapage `.insights/backfill-lead-geo-point-2026-09-17.php` (**A REJOUER EN PROD**, `--apply`). ⚠ Un lead issu d'une annonce reste non type : il n'apparait dans le matching des acheteurs qu'apres « Definir le type de projet » (filtre `requirement_type` de `findMatchingProperties`, inchange).
- **Invitation à signer (compromis, baux, offre) : `{{lead.adresse}}` en clair dans l'objet (2026-09-17)** : le défaut et son correctif vivent dans core-city-user (`SendSignerInvitations`, cf. son CLAUDE.md) - l'objet et le corps n'étaient résolus que sur 7 variables de signataire. Côté site : `dispatchSignerInvitations()` et la relance d'un signataire transmettent désormais `$this->site->id` au job, en **positionnel** (un package pas encore à jour ignore l'argument). Harness : `.insights/signer-invitation-variables-2026-09-17.php` (legacy 4/4, fixed 18/18 dans les deux projets).
- **Mode FULL (menu seul) : un clic sur une carte de parcelle affiche desormais la carte (2026-09-17)** : signale « avec `contentBarToggleMap` sur FULL, cliquer sur une `parcel-card` selectionne bien la parcelle et joue le fitBounds, mais on ne voit rien puisque la carte est masquee ». Les 5 gestionnaires de clic (`search.js` `.resultListShowParcel`, `opportunity.js`, `history.js` `.historyListShowParcel`, `advert_card.js` top-10 + navigation precedent/suivant) ne geraient que le mobile (`if(isMobile() && !isShowMap()) showMap()`), jamais le FULL de bureau. **Livre** : helper unique **`window.ensureMapVisibleForSelection(callback)`** (`responsive.js`, a cote de `toggleMapVisibility`) - carte deja visible → callback immediat ; selecteur FULL/HALF/MAP affiche (`innerWidth >= 1200 && !isMobile()`, meme seuil que `_content-bar.scss` et que la barre de navigation mobile) → `setViewMode('HALF')` puis callback a **120 ms** (le resize de la carte part a 50 ms : un fitBounds sur une carte de taille nulle calcule un mauvais zoom) ; sinon → `showMap(0, callback)`, le chemin du bouton « Carte » de la navigation mobile (carte seule, bouton qui passe a « Menu », `needFitBounds` differe applique). ⚠ Sous 1200 px on n'appelle PAS `setViewMode('MAP')` : il ecrirait la preference `map_visible_<menu> = MAP` (retrouvee ensuite sur ordinateur) et la navigation mobile travaille avec `showMap`/`showSearch`, pas avec `_viewMode`. ⚠ La bascule en HALF **est memorisee** comme preference du menu (meme precedent que la selection d'un resultat de l'omnibar, `toggleMapVisibility(true)`). **RÈGLE** : tout nouveau clic de liste qui localise sur la carte passe par ce helper, plus par le test `isMobile() && !isShowMap()`. **Verifie dans le navigateur** (menu Opportunites) : bureau FULL → clic reel sur une carte → mode HALF, selecteur synchronise, fitBounds joue sur une carte de 2 520 px de large, zoom 19 centre sur la parcelle ; chemin mobile avec une vraie carte (largeur forcee) → carte seule, fitBounds sur la carte pleine largeur, zoom 8 → 20 ; iframe reelle de 416 px → carte affichee, menu masque, bouton « Menu » qui ramene la liste, preference FULL non ecrasee ; 0 erreur console imputable. ⚠ Un onglet Chrome en arriere-plan n'initialise pas Google Maps (`map is not defined` au chargement = artefact) ; `resize_window` n'a aucun effet sur une fenetre maximisee → tester le mobile dans une iframe same-origin etroite. `npm run prod` requis (bundle), 0 route, 0 vue, 0 package.
- **Menu Opportunites : le tri de la barre d'outils ne triait rien (2026-09-17)** : signale « je clique sur "Superficie la plus petite", la liste se rafraichit mais reste triee par date ». `OpportunityController::search` **forcait** `opportunity_published_at desc` des qu'aucune zone geographique n'etait choisie (le cas nominal de ce menu), et remappait toujours `score` vers la date **en ecrasant le sens** : le choix de l'utilisateur partait bien (`manualSort=true&sort=area`) mais etait jete. ⚠ **Le forcage n'apportait aucun gain** : dans `HasSearch`, le tri s'applique a la fenetre DEJA dedupliquee et bornee par `maxResult` (20 000 lignes), quel que soit le critere - mesure sur la vraie route, sans zone : defaut 0,59 s, recentes 0,53 s, anciennes 0,52 s, superficie asc/desc 0,63 s, commune 0,55 s (idem sur une commune : 0,27 a 0,42 s). **Livre (site seul, 0 fichier de package)** : sans tri manuel → les plus recentes d'abord (defaut inchange, avec ou sans zone) ; avec `manualSort` → `superficie` / `cadastre_id1` / `nom_commune1` respectes, tout le reste = recence **avec la direction demandee**. Ce menu n'ayant **aucun scoring indicatif**, les deux options « Le moins / le plus pertinent » n'avaient pas de sens : elles deviennent **« Les plus recentes » / « Les plus anciennes »** (`data-sort="score"` conserve pour la plomberie JS commune avec la recherche cadastrale : `getSortData`, `manualSortChanged`), actives par defaut dans `opportunity_toolbar.blade.php` ET `#oppMobileSortList` (`opportunity_filters.blade.php`) - avant, « Superficie la plus petite » etait surlignee alors que la liste etait triee par date. **Verifie** : ordre controle en base (superficies 7, 11, 13, 16 m² en asc ; 2 127 890, 248 110... en desc ; dates 2026-08-31 en tete des recentes, 2006-02-24 en tete des anciennes), pagination page 2 sous tri manuel (42 + 42, 0 recouvrement), menu rendu (2 listes, 0 « pertinent »). ⚠ **Limite du moteur, inchangee** : quand la recherche depasse `maxResult` (compteur « 20 000+ »), le tri porte sur la fenetre des 20 000 premieres parcelles par identifiant, pas sur tout le resultat - vrai pour TOUS les tris, y compris « les plus recentes » depuis toujours ; avec une zone (commune, secteur) le resultat tient dans la fenetre et le tri est exact. ⚠ Le reglage `result_list_sort` reste **partage** avec la recherche cadastrale (un tri manuel ici l'ecrit), comportement anterieur. 🟥 **Second defaut, trouve seulement au test utilisateur** : le correctif serveur ne changeait RIEN a l'ecran, car `opportunity.js` (`loadOpportunityList`, `showMoreOpportunity`) envoyait `sort` + `direction` mais **jamais `manualSort`** - or `SearchParametersExtractor` n'applique le tri demande que si ce drapeau est vrai. La premiere verification avait ete faite en curl AVEC le drapeau, donc sans exercer ce que le navigateur envoie reellement. Corrige par `getOpportunitySortData()` : `manualSort` est deduit de l'option SURLIGNEE (vrai sauf pour le defaut « Les plus recentes »), pas de `window.manualSortChanged` qui est partage avec la recherche cadastrale et remis a false par elle - ce qui est surligne est ce qui est applique, et le defaut n'ecrit pas la preference a chaque chargement. La pagination envoie le meme drapeau (sinon curseur d'un autre tri). **Reverifie DANS LE NAVIGATEUR, par clic reel sur chaque option** : requete emise (`manualSort=true`, sauf le defaut) et ordre affiche (superficies 23, 23, 27, 30 m² puis 36 897, 22 445...), page suivante 42 → 84 elements croissants de bout en bout, 0 doublon. **Lecon : un correctif cote serveur se valide avec la requete que le NAVIGATEUR emet, pas avec un curl ecrit a la main.** `npm run prod` requis (bundle) + `view:clear`.
- **Partage d'un lead : notification de l'equipier + « Partage par ... » visible (2026-09-17)** : demande « quand on partage un lead il faudrait une notification, et qu'on puisse voir qui nous l'a partage ». **Etat de depart** : un partage (`PUT /acquisition/lead/{id}/assign`, `POST .../assignees`, table `lead_assignees`) n'envoyait qu'un **e-mail ecrit en dur et expedie dans la requete HTTP** (lien `/acquisition?lead=ID`), **aucune cloche ni temps reel** ; l'entree de timeline s'appelait « Transfert lead » ; et cote equipier, l'auteur du partage n'apparaissait que dans le `title` d'une icone du kanban (invisible au doigt), **nulle part sur la fiche**. 🟥 **Defaut trouve en lisant le code** : `assignLead` reposait `assigned_at` + `assigned_by_user_id` sur TOUS les equipiers a chaque `sync()` (et `attachAssignee` sur un equipier deja assigne) - « qui a partage, et quand » etait ecrase a chaque modification de la liste. **Livre** : (1) notification via **`LeadPortalActivityNotification`** du package core-city-user, nouvel evenement **`EVENT_LEAD_SHARED = 'lead_shared'`** (meme precedent que `widget_lead_captured`) → cloche du tiroir Notifications (icone `fa-share-alt`, entree `lead_shared` de `HasDashboardView::portalEventMeta`), **toast temps reel cliquable** (type `lead.portal.lead_shared`, deja gere par `auth.js` du core), push web/mobile, et **e-mail systematique** (evenement ajoute a `HIGH_PRIORITY_EVENTS`, il remplace l'e-mail direct). Titre adresse au destinataire - « Claire Dubois a partage un lead avec vous » - construit depuis `metadata.by_name` de l'activite, alors que la timeline dit « 🤝 Lead partage avec Hugo Lemaire (par Claire Dubois) ». 🟥 **Pourquoi pas une classe de notification dans le site** : elle est mise en file, et les workers tournent depuis /administration ou `App\...` de ce site est inconnu (meme piege que `app/Jobs/`, 2026-08-06). ⚠ **Deploiement tolerant a l'ordre** : `notifyAssignmentChange` teste `defined(LeadPortalActivityNotification::EVENT_LEAD_SHARED)` ; package pas encore a jour → repli `sendLegacyLeadSharedMail()` (l'ancien e-mail, lien corrige en `/acquisition/{id}`). Le destinataire est resolu en **`App\Models\User`** (type sous lequel le tiroir relit la cloche) ; l'auteur ne se notifie jamais lui-meme ; un retrait de partage ecrit l'activite mais ne notifie personne. (2) **Auteur et date conserves** : `assigned_at` / `assigned_by_user_id` ne sont poses que pour un NOUVEL equipier. (3) helper **`lead_shared_by($lead, $viewerId = null)`** (`lead_capability_helpers.php`) : null pour le proprietaire ou un non-assigne ; lit le pivot de la relation `assignees` deja chargee, repli sur le proprietaire pour les partages anterieurs a la colonne, utilisateurs memoises (pas de N+1 sur une liste). Consomme par `_lead_header_labels` (puce « Partage par Claire Dubois · 17/09/2026 », re-rendue par `renderHeaderLabelsHtml`), `kanban_card` et `lead_list_item` (puce « Claire D. » **en clair**, plus seulement en tooltip). **Verifie** : partage reel par la vraie route en session → notification creee **par le worker** (`notifiable_type = App\Models\User`, `project_id = 1`, `event_type = lead_shared`, `page_url = /acquisition/{id}`), pivot `assigned_by_user_id` correct ; re-partage identique + `attach` d'un deja assigne → **0 nouvelle notification, date et auteur inchanges, 0 activite en plus** ; harness `.insights/lead-shared-notification-2026-09-17.php` **17/17** (vues cote equipier et cote proprietaire, titre, lien, e-mail rendu sans tiret cadratin, icone du tiroir) ; PHPUnit 157 / 3 preexistants. ⚠ En dev l'e-mail echoue en `failed_jobs` (bac a sable Mailgun qui refuse les destinataires non autorises) : la cloche est ecrite avant, ce n'est pas un defaut. ⚠ Le coequipier demo 214300 n'a aucun abonnement (432 sur toute page) : ses vues se testent par le harness, pas en session. **Deploiement : tag core-city-user + `composer update` sur rs_infoparcelle_application ET administration + `queue:restart` ; cote site `view:clear`. 0 migration, 0 route, 0 JS.**
- **🟥 Editeur photo : en PRODUCTION, « Appliquer » ecrasait la photo en 1 x 1 pixel (2026-09-17)** : signale « en local tout marche, en production des que j'applique un filigrane l'image est cassee, elle devient un pixel ». **Diagnostic mene sur le fichier de prod lui-meme** : PNG **valide** de 91 octets, blocs `IHDR,pHYs,IDAT,IEND` (= ecrit par GD, pas Imagick), pixel unique `#DADCE0` **opaque** - donc pas une image vide mais **le coin haut-gauche de la photo : un recadrage 1 x 1**. Chaine : (1) `public.rootsud.fr` ne renvoie **aucun en-tete CORS**, alors que le stockage de dev (ngrok) renvoie `access-control-allow-origin: *` - **c'est la seule difference prod / local** ; (2) Cropper.js 1.6 (`checkCrossOrigin: true` par defaut) recharge l'image en `crossOrigin="anonymous"` : sans CORS ce rechargement echoue, le cropper ne devient **jamais pret** (aucun `.cropper-container`), sans aucune erreur visible puisque l'apercu, lui, s'affiche ; (3) `photo-editor.js` envoyait `_cropper.getData(true)` **des que l'objet existe**, meme pour un filigrane seul - or `getData()` d'un cropper non pret vaut `{x:0,y:0,width:0,height:0}` ; (4) `EstimationController::editPhoto` faisait `max(1, (int) $crop['width'])` → `crop(1, 1, 0, 0)`. ⚠ Un clic sur « Appliquer » **sans rien modifier** detruisait donc aussi la photo. **Livre** : cote JS, `checkCrossOrigin: false` + `checkOrientation: false` (le recadrage est fait par le SERVEUR, aucun canvas n'est lu ; l'orientation EXIF est deja corrigee a l'upload) → le cropper fonctionne sans CORS ; drapeau `_cropperReady` pose par l'evenement `ready` ; **`getEffectiveCrop()`** ne rend un recadrage que si le cropper est pret, que la zone fait ≥ 2 px et qu'elle ne couvre PAS toute l'image - un filigrane ou un reglage seul n'envoie plus de recadrage (et atteint enfin le « Cas 1 » de `editPhoto`, qui sait repartir de `_original/`). Cote serveur, **`normalizePhotoCrop()`** : largeur ou hauteur < 2 = pas de recadrage (jamais de `max(1, ...)`), zone bornee a l'image, meme regle dans `replayNonWatermarkEdits` (un recadrage degenere deja en historique n'est pas rejoue). **Verifie** : A/B serveur sur la vraie route avec le controleur de HEAD restaure - **legacy : recadrage nul → fichier 1x1 de 91 octets, identique a celui de prod** ; fixed : 422 « Aucune modification », photo intacte ; recadrage nul + luminosite → 1200x800 conserve ; vrai recadrage 600x400 OK ; « Revenir a l'original » restaure. Navigateur : editeur local ouvert sur l'image de PROD (sans CORS) - avant, cropper jamais construit et `crop = 0/0/0/0` poste ; apres, cropper construit et aucun recadrage poste. **Reparation des photos deja abimees** : `.insights/repair-photos-crop-zero-2026-09-17.php` (liste seule par defaut, `--apply` restaure depuis `_original/`, idempotent, **A REJOUER EN PROD** depuis le projet dont `STORAGE_PUBLIC_PATH` vise le stockage reel) - l'original est toujours la : la sauvegarde `_original/` est ecrite AVANT le recadrage. ⚠ **Piege de nettoyage** : `$item->delete()` (Spatie) supprime TOUT le dossier `<id>/` du stockage ; en dev ce dossier peut contenir un fichier orphelin d'une ancienne base (ids reutilises) - supprimer en SQL (`medias` avant `media_items`, FK `medias_media_item_id_foreign`) puis ses seuls fichiers. ⚠ Ajouter des en-tetes CORS sur `public.rootsud.fr` n'est plus necessaire pour l'editeur, mais reste le seul ecart de configuration connu entre le stockage de prod et celui de dev. `npm run prod` requis (bundle), 0 migration, 0 route, 0 package.
- **Modale a `<form>` enfant DIRECT du `modal-content` : pas de scrollbar, pied qui flotte (2026-09-17)** : signale sur `#modalSendLettreProspection` (l'apercu ajoute le meme jour a allonge le contenu et rendu le defaut visible) - « il manque la scrollbar, prends exemple sur `modalSendMessage` ». `.lead-modal-modern` rend le `modal-content` en flex colonne a hauteur d'ecran, avec `.modal-body { flex:1; overflow-y:auto }`. `modalSendMessage` pose header / body / footer en enfants directs ; la lettre de prospection (et `#modalSendDemandePieces`) enveloppent body + footer dans un `<form>` enfant direct, qui sortait de la chaine flex : la regle existante ne couvrait que **`.modal-view > form`**. Generalisee en **`> form, .modal-view > form`** (`_lead-actions.scss`). A/B dans le navigateur, contenu allonge de 1 500 px : sans la regle, corps non defilable et pied a 2 560 px pour une fenetre de 1 321 px ; avec, le corps defile (1 288 px) et le pied reste colle en bas, identique a la modale de reference. La demande de pieces est reparee du meme coup (corps de 3 015 px desormais defilable). **RÈGLE** : dans une modale `lead-modal-modern`, un `<form>` qui enveloppe body + footer doit etre enfant direct du `modal-content` ou d'une `.modal-view` ; tout autre wrapper intermediaire recasse le defilement. SCSS seul : `npm run dev|prod` + hard-reload.
- **Lettre de prospection : apercu de ce qui part, modifiable avant l'envoi (2026-09-17, 2e passe)** : retour utilisateur « on ne voit pas ce qui va etre envoye, je ne peux rien modifier ». La modale `#modalSendLettreProspection` porte un bloc `#slpPreview` qui suit le canal ET le modele choisi : **e-mail** = objet (`#slpEmailSubject`) + corps `contenteditable` (`#slpEmailHtml`) ; **SMS** = `#slpSmsBody` + compteur caracteres / segments ; **PDF** = iframe `#slpPdfFrame` sur le rendu REEL du modele (0,8 s) + « Ouvrir en grand » + lien vers Parametres › Templates (un PDF pleine page ne s'edite pas dans une modale). **2 routes GET** (`planaccess:ACCESS_CRM`, `sharedFor`, lead d'autrui → 404) : `acquisition/lead/{id}/lettre-prospection/preview?channel=email|sms&template_id=` (`lettreProspectionPreview`) et `.../preview-pdf` (`lettreProspectionPreviewPdf`, `Content-Disposition: inline`, rien n'est stocke). 🟥 **L'apercu ne cree AUCUN espace client** : `ensureProspectionPortalToken` ecrit une activite et emet `portal_token_created` (qui peut faire avancer le pipeline) - ouvrir une modale ne doit rien declencher. Le lien est donc rendu par le marqueur **`AcquisitionController::PROSPECTION_PORTAL_PLACEHOLDER` = `[lien-espace-personnel]`** (dans le `href` du bouton de l'e-mail, en clair dans le SMS), remplace a l'envoi par `applyProspectionContentOverrides()` : vraie URL pour l'e-mail, **court-lien de suivi** pour le SMS (meme appel `generateClickShortUrl` que le job). Le PDF d'apercu, jamais envoye, pointe sur le token existant ou sur une adresse de demonstration. **Le contenu n'est transmis que s'il a ete MODIFIE** (`$modal.data('slpDirty')`, pose sur `input`) : sans edition le job rend le modele comme avant, zero regression. Le contenu edite est ecrit dans `proposal.metadata` sous les cles **deja lues par les jobs** (`custom_email_subject` / `custom_email_html` / `custom_sms_body`, celles de l'envoi d'avis de valeur) : **0 fichier de package**. Corps e-mail nettoye cote serveur (balises `script|style|iframe|object|embed` et attributs `on*` retires). `slpLoadPreview($modal, templateId)` porte un numero de sequence : la reponse d'un canal deja quitte est ignoree. ⚠ Changer de canal ou de modele **recharge l'apercu et repart du nouveau modele** (les modifications en cours sont perdues, voulu). ⚠ Le chargement d'un PDF dans l'iframe **detache le debogueur** de Claude in Chrome (« Detached while handling command ») : relire l'etat dans un second appel. **Corrige au passage** : la carte Email annoncait « PDF + corps personnalise » alors que l'e-mail d'approche (modele 79, sans HTML de document) **ne joint aucun PDF** ; et l'objet du modele systeme 79 portait un tiret cadratin (`.insights/sql-modele-79-emdash-2026-09-17.sql`, **A REJOUER EN PROD**). **Verifie** : les 3 endpoints en session curl (200 / 200 / PDF 389 Ko inline / 404), harness serveur `.insights/lettre-prosp-preview-overrides-2026-09-17.php` **14/14** (rien d'ecrit sans modification, marqueur remplace, script retire, SMS avec court-lien `/t/...`, et **le job SendModelByMail envoie bien l'objet et la phrase de l'agent** - transport `array`, rien ne sort), navigateur : 3 apercus, PDF qui suit le changement de modele, e-mail non modifie → aucun contenu poste, modifie → objet + corps + marqueur, SMS modifie → `sms_body` ; PHPUnit 157 / 3 preexistants ; 0 ligne de log ; donnees supprimees.
- **🟥 Lettre de prospection : le bouton disparaissait selon le PIPELINE, et la modale s'ouvrait sur Email meme sans contact (2026-09-17)** : signale « des qu'on ajoute un contact au lead le bouton `ldc-quick-action-lettre-prosp` n'apparait plus, et sans contact on ne peut rien choisir dans la modale ». **(1) Visibilite** : 4 regles divergentes. Les actions rapides testaient le statut LEGACY (`$current === Lead::STATUS_TO_CONTACT`) : sur un pipeline dont la 1re colonne porte un trigger `contact_linked` (ou `contact_has_email_or_phone`) vers une colonne de `base_status_id = 1` - agence demo 221 « A contacter → Qualification », pipelines prestataire - **ajouter un contact faisait passer `leads.status` a 1 et le bouton disparaissait** ; sur « Vendre un bien » (trigger reserve aux acheteurs) ou `qualification_funnel` (`base_status_id` null, statut legacy fige a 0) il restait. Le menu ⋮ Documents n'avait au contraire AUCUNE condition de statut (lettre proposee sous mandat, et meme sur un lead perdu quand la colonne « Abandonne » du pipeline est de base 6 et non 5) et ignorait les leads non types. **Livre** : regle unique **`lead_prospection_letter_available($lead, $activeDocKinds = null)`** (`app/Helpers/lead_capability_helpers.php`, + `lead_active_doc_kinds()`) = capacite `hoguet.docs` + type non type / vendeur / bailleur + ni perdu ni gagne (statut legacy 4/5 **et** categorie `lost`/`won` de la colonne) + aucun mandat actif ; consommee par `_lead_quick_actions` (les 2 sections) et `_lead_breadcrumb_actions` (item sorti du bloc `$isTyped`). ⚠ **Ne JAMAIS re-conditionner une action de la fiche lead au statut legacy `$lead->status`** : sa valeur depend du `base_status_id` des colonnes du pipeline choisi, pas d'une regle metier. **(2) Modale** : le canal n'est plus force a Email. `slpDefaultChannel(contacts)` (`phase2-actions.js`) choisit Email si un contact DU DOSSIER a un e-mail, sinon SMS s'il a un telephone, sinon **PDF**. Sans aucun contact (`#slpChannelGroup.is-nocontact`) seule la carte PDF est affichee, avec l'encart `#slpNoContactNote` (« Le QR code de la lettre mene a l'espace personnel du destinataire... ») et le lien `#slpRevealChannels` « J'ai deja ses coordonnees » qui revele Email/SMS + le picker (placeholder « Chercher dans mon carnet ou creer avec + ») - le carnet d'adresses et la creation inline restent donc accessibles (decision du 2026-08-07). `slpApplyChannel($modal, ch)` est partage par l'ouverture et le changement de canal ; l'etat est **recalcule a chaque ouverture** (1 GET `/contacts` via `resolveLeadContactPreselections`, la modale s'affiche dans son callback). Toast et BlockUI : « PDF de la lettre de prospection genere » / « Generation du PDF... » en canal download. ⚠ Le `_template_picker` masque quand il y a ≤ 1 modele est **voulu** (en PDF il y a 4 modeles systeme, il s'affiche). **0 migration, 0 route, 0 fichier de core** : `npm run dev` (ou `prod`) + `view:clear`. **Verifie** : harness A/B `.insights/lettre-prosp-visibility-2026-09-17.sh` (vues de HEAD restaurees : **legacy 15/15**, bouton absent apres ajout du contact ; **fixed 21/21**), `LeadProspectionLetterVisibilityTest` 4/4, PHPUnit **157 passes / 3 echecs preexistants**, E2E navigateur sur 3 leads fabriques (`.insights/lettre-prosp-modal-fixtures-2026-09-17.php setup|cleanup`) : 0 contact → PDF seul + encart, lien de revelation → Email + picker, telephone seul → SMS + badge de quota, e-mail → Email + contact preselectionne, reouverture → etat reinitialise, **PDF reellement genere** (200, 396 Ko, `%PDF`), payload e-mail inchange (`channel=email&contact_id`), ajout d'un contact par la vraie route → lead en « Qualification » (legacy 1) et bouton toujours present aux 2 endroits ; 0 erreur console, 0 ligne de log, donnees supprimees.
- **Geolocalisation des adresses SIRENE (2026-09-02)** : le defaut et son correctif vivent dans **core-person** (`EstablishmentImport`, `EstablishmentLocationImport`, `HasPerson::localiseVoie`/`localiseLatLng`, cf. `core-person/CLAUDE.md`). Cote site : harness A/B rejouable `.insights/sirene-address-geoloc-2026-09-02.php` (**a lancer depuis administration**, `HARNESS_MODE=legacy|fixed`, 40 assertions, SIRET de test `99999999900001..11` sur Perpignan, nettoyage en `finally`) et script de reparation `.insights/fix-location-quality-2026-09-02.php` (`FIX_OPTS['file']` = CSV INSEE decompresse de la task « Sirene Geolocation », dry-run par defaut, `apply`, `selftest`) - **A REJOUER EN PROD** apres le prochain passage de la task. ⚠ `core-person` n'a **pas d'override PSR-4** ici (Reflexion du 2026-09-02) : copier dans `vendor/` pour tester, tag + `composer update` pour deployer. Onglet occupants de la fiche parcelle et `GET /api/v1/occupants` inchanges (lecture seule, verifies en 200).
- **Nombre d'etages « (estimé) » dans `card_building` (2026-09-02)** : le libelle par defaut du dropdown `estimation_nombre_etage` porte « (estimé) » des qu'une parcelle a `detail.nombre_etage_calculate = true` (niveaux deduits d'un a priori de section, cf. `core-city/CLAUDE.md`). Le fragment `POST /parcel` rend « 2 étages (estimé) » sur une parcelle recalculee et « 2 étages » sur une parcelle mesuree. ⚠ `nombre_etage` est un nombre de NIVEAUX (1 = plain-pied) alors que le catalogue `bdOptions['etage']` et la surcharge utilisateur sont en ETAGES (0 = RDC) : incoherence d'affichage preexistante, non traitee. 0 route, 0 JS, 0 build : `view:clear` suffit cote site ; le reste du lot est dans core-city (tag + `composer update` + `migrate` + `queue:restart`).
- **Permis Sitadel sans prefixe de section (2026-09-02)** : le defaut et son correctif vivent dans **core-city** (`Imports/PermitImport` + `Helpers/PermitParcelResolver`, cf. `core-city/CLAUDE.md`). Cote site : harness rejouable `.insights/permit-import-prefix-2026-09-02.php` (38 assertions, extrait Sitadel reel `permit-import-prefix-2026-09-02/sitadel-extract.csv`) **a lancer depuis ce projet** car c'est lui dont `pgsql_city` pointe sur la base aux volumes de prod (172.20.0.1:5430) - il ecrit puis supprime des permis de test et verifie les compteurs avant/apres. ⚠ Il purge les cles de cache `city.permit.prefix_map.{49,12,56,79}` avant sa section 4.
- **Estimation batch : l'emprise au sol brute ne sert plus de surface habitable (2026-09-02)** : le defaut et son correctif vivent dans **core-city** (`HasEstimation::getSurfaceHabitableEstimation` + `ParcelDetailEstimationJobs::getDataFeature`, cf. `core-city/CLAUDE.md`). Cote site, **rien ne change a l'ecran** : l'onglet Estimation de la fiche parcelle passe par le mode live de `HasMultiParcel`, qui derivait deja la surface par `calculerSurfaceHabitable` - verifie en navigateur, `66136000BT0117` (emprise 0,58 m²) affichait deja 0 m² / 0 EUR et `66136000EO0306` affiche 181 m² / 378 000 EUR avant comme apres. Ce qui change, c'est la colonne `fr_parcel_details.estimate_price` lue par le pricemap, l'API, les filtres `estimation*` et l'onglet Fiscalite : 114 lignes en dev (12 148 en prod) portaient un prix de 66 a 2 767 EUR calcule sur une emprise de moins de 0,6 m². **Livrables dans `.insights/`** : `estimation-surface-repli-2026-09-02.php` (harness `legacy|fixed`, **a lancer depuis administration** - `RS_APP_ROOT` - car c'est le projet dont `pgsql_city` pointe sur docker-db-1 ; 19/19 puis 32/32, 300 parcelles au md5 identique), `sql-estimation-emprise-brute-2026-09-02.sql` (reparation idempotente, **A REJOUER EN PROD** : 12 148 lignes attendues), `mesure-estimation-emprise-brute-2026-09-02.sql` (lecture seule, deja jouee sur la base aux volumes de prod : 0 emprise ≥ 1 m², donc le SQL suffit sans rejouer `ParcelDetailTask`). ⚠ **`.env` du site : `pgsql_city` = tunnel `172.20.0.1:5430`** - il etait mort en debut de session (`Connection refused` dans `laravel.log` a 17:02, `select 1` sans reponse) et il est revenu plus tard sous un nouveau processus ssh ; quand il est mort, la fiche parcelle est inutilisable et tout harness doit partir d'administration. ⚠ **Deploiement** : tag core-city + `composer update` ici et sur administration + `queue:restart` + le SQL ; aucun build, aucune vue.
- **🟥 Photos d'iPhone (HEIC) refusees, et 3 defauts front decouverts au passage (2026-08-26)** : signale « impossible d'ajouter des photos » avec `Unsupported image type image/heic` sur `POST /estimate/picture`. **La cause serveur est dans le core** (`HasMedia:80`, transcodage HEIC → JPEG - voir `core/CLAUDE.md`). Cote site, trois defauts **independants** aggravaient le symptome : **(1)** `handlePhotoFiles` (`tab-estim-photo.js`, `tab-lead-photo.js`) filtrait sur `file.type.startsWith('image/')` - or une photo d'iPhone arrive **parfois sans type MIME** ou en `application/octet-stream` : elle etait alors ecartee **EN SILENCE**, l'utilisateur cliquait et il ne se passait **rien du tout** (aucune requete, aucun message). Remplace par `window.isSupportedPhotoFile()` (type **ou** extension), + un avertissement quand un fichier est reellement ignore. Le module preview (`preview-modern.js`) avait le meme travers en pire : une **liste blanche** `['image/jpeg','image/jpg','image/png','image/gif']` avec `return true` muet, y compris pour un fichier trop lourd. **(2)** L'apercu local (`FileReader` → `data:image/heic`) est une **image cassee** dans Chrome (contre-epreuve mesuree en Chrome 149) : `window.applyUploadedPhotoPreview()` recale la vignette sur le JPEG renvoye par le serveur (`response.url`, deja present depuis le 2026-08-05), proactivement pour les formats non affichables et via un filet `onerror` sinon. **(3)** Les toasts d'erreur affichaient un « Erreur lors de l'upload » **opaque** : `uploadPhoto` ne transmettait que le `statusText`. Il remonte desormais le message du serveur. ⚠ **Deux formats de corps coexistent sur cet endpoint** : le Handler du core repond en **JSON** (`{message: ...}`) des que jQuery envoie `X-Requested-With`, tandis que les gardes du controleur (`response('Limite de 15 photos atteinte', 422)`) repondent en **texte brut** - afficher `responseText` sans distinguer les deux collerait un JSON dans le toast. **Defaut de robustesse corrige au passage** : `uploadPhoto`/`removePhoto` ne lisaient **jamais** le `data-url` que les vues posent pourtant sur `#photoFileInput` / `#leadPhotoFileInput` ; l'URL venait uniquement de `.uploadPhotoSection` / `.preview-add-btn` / `#photoUpload-{pid}`. Un ecran affichant la liste de photos sans ces elements echouait **avant meme d'emettre la requete** - donc « Erreur lors de l'upload » sans **aucune ligne cote serveur** (rencontre pendant la recette). Les deux inputs sont ajoutes **en fin de cascade** : rien ne change quand les elements actuels sont presents. **0 migration, 0 route, 0 vue de core.** **Les 2 `accept` restrictifs ont ete elargis au HEIC** (`image/heic, image/heif, .heic, .heif` en plus des 3 types d'origine) : `_preview_module.blade.php` (onglet Info de la fiche parcelle **et** fiche lead) et `card_estimate_tab_picture_upload.blade.php`. ⚠ **Les extensions sont indispensables en plus du MIME** : certains OS ne mappent pas le type d'un HEIC, qui reste alors **grise** dans le selecteur de fichiers. **Ce que cela change, et pourquoi ce n'etait pas anodin** : `accept` a **deux** effets - (1) sur ordinateur il filtre la boite de dialogue, donc un `.HEIC` rapatrie d'un iPhone etait **impossible a selectionner** ; (2) **sur iPhone, un `accept` restrictif fait transcoder la photo en JPEG PAR iOS a la selection** - l'application ne recevait donc jamais de HEIC sur ces deux ecrans, et tout fonctionnait deja. Les elargir deplace la conversion du telephone vers le serveur : c'est **preferable** (qualite, orientation EXIF et profil ICC maitrises) **a la condition que le delegue HEIC soit present** - confirme par l'utilisateur le 2026-08-26, **la production tourne la meme image Docker que le dev**, ou il est disponible. ⚠ Le comportement (2) d'iOS est documente mais **n'a PAS ete mesure ici** (pas d'iPhone en recette). ⚠ **`card_estimate_tab_picture.blade.php` (qui inclut `..._upload`) est une VUE MORTE** : aucun `@include`, aucun controller, et son input porte l'id `photoUpload` alors que le JS cherche `#photoUpload-{pid}` - elargie par coherence, sur demande, mais elle ne rend rien. Le seul input restrictif reellement atteignable etait donc celui de `_preview_module`. **🟥 `accept="image/*"` NE SUFFIT PAS pour le HEIC** (constate a l'usage le 2026-08-26, apres coup) : Chrome n'etend pas le wildcard depuis la base MIME du systeme mais depuis **sa propre table interne**, qui n'inclut pas `.heic` sur Linux/Windows - le fichier reste **invisible dans le selecteur**, l'utilisateur devant basculer sur « Tous les fichiers » pour le voir. ⚠ Verifie que ce n'est pas l'OS : `/usr/share/mime/globs` mappe pourtant bien `image/heif:*.heic`. Les **2** inputs concernes (`#leadPhotoFileInput` de la fiche lead, `#photoFileInput` de l'onglet Photos parcelle) sont donc passes a **`accept="image/*,image/heic,image/heif,.heic,.heif"`**. **Regle a retenir : pour un format que Chrome ne connait pas, toujours enumerer type ET extension - un wildcard ne le couvre jamais.** **Sweep complet le meme jour, sur demande** : les **9** inputs d'upload d'image du site acceptent desormais le HEIC - photos parcelle et lead, module preview, logo et avatar de l'onglet Apparence, avatars `/compte` et `/parametres`, couverture de documents. **🟥 Elargir l'`accept` ne suffit PAS quand l'endpoint valide les types** : `SettingController::uploadStyleCover` exigeait `required|image|mimes:jpeg,jpg,png,webp` - **mesure : la regle `image` REFUSE un HEIC** (« Le champ cover doit etre une image »), car elle s'appuie sur une liste fermee jpg/jpeg/png/gif/bmp/svg/webp. Sans ce second correctif, l'utilisateur aurait pu **selectionner** son fichier pour se voir **rejeter** ensuite - pire qu'avant. Passe a `required|mimes:jpeg,jpg,png,webp,heic,heif|max:6144` : `mimes:` seul suffit et est meme **plus strict** que `image` (il n'ouvre ni le svg ni le bmp). Les autres endpoints (`uploadLogo`, avatar du core) ne valident que `required` : leur `accept` suffisait. ⚠ **2 inputs volontairement EXCLUS, pour deux raisons differentes** : (a) **le logo de filigrane de l'editeur photo** (`#phWatermarkLogoInput`) est traite **en canvas dans le navigateur** et n'est jamais envoye au serveur - Chrome ne sachant pas decoder un HEIC, l'accepter donnerait un filigrane **vide** ; (b) **le scan de document signe** (`#msmSignedPdf`, `markProposalSignedManual`) valide `mimes:pdf,jpg,jpeg,png` et **ne passe pas par `storeMedia`** - donc aucune conversion : elargir son `accept` produirait un rejet a la validation. ⚠ **Photographier un mandat signe avec son iPhone est pourtant un cas d'usage reel** : le faire fonctionner demande de router ce fichier vers la conversion, chantier a part. **Verifie** : couverture en **HEIC → 200** (JPEG 840x560 produit), **JPEG → 200** (non regresse), **PDF → 422** (la liste blanche reste stricte) ; `accept` elargi **rendu** dans les fragments reels ; base rendue a l'identique (la cle `document_cover_image` posee par le test **retiree** du groupe - `settings` ne contenait rien d'autre, et l'autre couverture en base appartient a un utilisateur different). Ce sont ces 2 inputs qui etaient impliques dans l'incident d'origine (l'upload aboutissait quand le fichier etait glisse-depose, ou choisi via « Tous les fichiers »). **Verifie** : `accept` elargi **rendu** dans la fiche lead reelle (`photoUpload-lead-1986`), et **upload HEIC de bout en bout par ce module** (`preview-modern.js`, celui qui portait la liste blanche stricte) → **200**, JPEG 600x800 affiche, apercu recale sur l'image du serveur, 0 erreur console, donnees supprimees. ⚠ **Signale NON traite** : `uploadPicture` valide seulement `['photo' => 'required']` - **aucune** regle de type ni de taille cote serveur, le « 10 MB max » de l'interface n'est donc pas verifie (seul `upload_max_filesize` borne). ⚠ **Rebuild `npm run prod` obligatoire** (les 4 fichiers vivent dans `public/js/app.js`, gitignore) ; **tag core + `composer update`** pour la partie serveur. Harness et diagnostic : `.insights/heic-upload-2026-08-26.php`, `.insights/diagnostic-heic-serveur-2026-08-26.php`.
- **🟥 Hub agence `/compte/equipe/{id}` : NE PAS remettre la classe `inputElementToken` sur `#agencyLinkToggle` (2026-08-26)** : signale « le toggle du lien d'invitation d'une agence donne une erreur, on appelle une URL avec `undefined` dedans, ex. `/compte/equipe/undefined/1` ». La vue `compte/agence/manage.blade.php` (incluse par l'override `vendor/core/user/account.blade.php` quand le groupe est une agence) portait cette classe **par mimetisme avec la vue core `team/account`**. Or c'est le **selecteur du handler du core** (`core/resources/js/user.js`), qui POSTe sur `$('.groupAddLink').attr('data-url')` - conteneur **absent de ce hub** → `undefined` → requete relative en **404**. **Mesure avant correctif, une seule bascule = 2 requetes** : `POST /compte/equipe/undefined/0` **404** (core) **puis** `POST /parametres/agence/254/lien` **200** (site, `setting-agency.js`). Le reglage etait donc **bien enregistre** : seul le toast d'erreur du 404 trompait l'utilisateur. Classe retiree (elle n'apporte **aucun style** : 0 occurrence en SCSS) ; le core a recu en plus une garde `if (!url) return;` (defense en profondeur). ⚠ La vue reutilise d'autres classes du core (`formInputLink`, `groupToken`, `groupForm`, `groupName`, `groupUserEditForm`) : verifiees sans effet ici (aucune ne construit d'URL, leurs boutons declencheurs sont absents), mais **toute reprise d'une classe du core doit etre verifiee de la meme facon** - ce sont des selecteurs de comportement, pas des classes de style. **0 migration, 0 route, 0 JS de site, 0 fichier PHP** cote site : un `view:clear` suffit. ⚠ **Le durcissement du core, lui, exige un tag core + `composer update` + recopie du bundle `public/core/js/app-core.js` et alignement du hash dans `public/core/mix-manifest.json`.** Verifie : A/B avec la classe remise (1 requete, 200, plus aucun `undefined`), non-regression du core sur une equipe simple fabriquee puis supprimee, 0 erreur console, base rendue a l'identique (lien d'invitation de l'agence de test restaure dans son etat d'origine).
- **🟥 Onglet Apparence : le logo n'etait JAMAIS enregistre (2026-08-25)** : signale « je mets mon logo, ca marche bien, mais au rafraichissement il n'est plus la ». `POST estimate/logo` repondait **400 « Aucune parcelle selectionnee »** - `EstimationController::uploadLogo` exigeait un parametre **`p`** (reference cadastrale) que l'onglet Apparence de `/parametres` ne peut structurellement pas fournir : il n'a **aucune parcelle ouverte**. C'est un **vestige** de l'ecran de personnalisation historique, loge dans la fiche parcelle (rs_captimmo `settings_general.js` l'envoie encore depuis `#parcelCard[data-parcel]`) ; `$id` et `$encodedId` etaient calcules puis **jamais reutilises** - le logo est un reglage de COMPTE, sans lien avec une parcelle. `p` est desormais **optionnel** (accepte et controle quand il est transmis). **🟥 Second defaut, celui qui rendait l'echec invisible** : le callback d'erreur appelait `toast({...}, 'danger')`, or **`toastr['danger']` n'existe pas** (types valides : `success`/`info`/`warning`/`error`) → **TypeError, aucun toast**. L'apercu local (`FileReader`) s'affichait quand meme : rien ne signalait l'echec, d'ou « ca marche bien ». **18 occurrences** corrigees dans le projet (`style.blade.php` x2, `documents.blade.php` x6, `agenda.js` x7, `dashboard.js` x1, `photo-editor.js` x2) - chercher **les deux** motifs `'danger')` ET `'danger',` (forme a 3 arguments), un grep sur le seul premier en rate 2. **Verifie** : reproduction du 400 avant correctif sur la vraie route en session reelle, puis **200 sans `p`** / **200 avec `p`** (non-regression captimmo) / **400 sans fichier** ; E2E navigateur avec un **vrai upload** (canvas → `DataTransfer` → `change`) : XHR **200**, toast de succes, et surtout **logo toujours present apres rechargement du fragment** (`st-style-logo-empty` disparu, image servie en 200 `image/png` 400x120) ; preuve du toast muet mesuree dans la page (`'danger'` → `TypeError` + 0 toast, `'error'` → toast affiche) ; 0 erreur console, base rendue a l'identique (2 `user_settings`, 4 medias, 4 `media_items` et leurs fichiers supprimes). **0 migration, 0 route, 0 fichier de core** → aucun tag, aucun `composer update`. ⚠ **`npm run dev` requis** (agenda.js / dashboard.js / photo-editor.js sont dans le bundle). ⚠ **Constate et NON traite, defaut latent distinct** : un fichier image invalide (extension correcte, contenu corrompu) fait sortir `uploadLogo` en **500** non gere (`Unable to decode image from file`, Intervention Image) au lieu d'un 400 explicite.
- **🟥 Bloc Parcelles de l'API audite (2026-08-19)** : `GET /sections/{c}/{s}/parcels` renvoyait **500** (methode inexistante, route ni documentee ni appelee) - retiree, elle rend un 404 propre. **`limite` n'avait aucun plafond** : la doc annonce « Maximum: 50 », l'API servait **3 239 parcelles** (tout le stock d'une commune) - refuse desormais en 400 au-dela de 50. `champs=zzz` rendait des enregistrements vides, un `champ_tri` inconnu retombait **en silence** sur le tri par defaut, `format=xml` et `superficie_min=12,5` passaient sans un mot : les 4 endpoints sont desormais valides. ⚠ **Le POST `/parcelles/recherche` porte ses options dans le CORPS JSON** - il faut aplatir `filtres` et `options` avant tout controle, sinon `options.champs` n'est pas vu. Tout vit dans **core-city** ; cote site, seule l'annotation Swagger de `CTBA_GET_ParcelController` a change (ajout de l'alias `code`). **🟥 Point ouvert mesure, NON traite** : `/parcelles/recherche` avec le champ `superficie` met **18 a 45 s** pour `limite=1` ou `2`, contre 0,4 a 0,8 s pour `limite=3` - reproductible sur 3 communes. Signature d'un plan « arret anticipe » qui bascule (meme famille que `maxResult`, 2026-08-06) : demande une campagne de mesure dediee, pas un correctif a la hate. **🟥 `/dvfs` ne rendait que 4 mutations sur 311 (2026-08-19)** : signale sur `?code_municipalite=74025`. Deux defauts preexistants - un **`->limit(10)` code en dur** dans le filtre commune (depuis le 2024-10-23) et un **curseur qui ne portait pas le tri par defaut** (6 pages rendaient 266 elements pour 141 distinctes). Apres : **311 elements, 311 distincts**. ⚠ **Effet sur l'application** : les comparables DVF de la **fiche parcelle** et du **PDF avis de valeur** demandaient 15 candidats et n'en recevaient que 10 - mais le **resultat final est inchange** (l'algorithme s'arrete a 8 comparables ; verifie identique sur 12 parcelles). Detail : `core-city/CLAUDE.md`. ⚠ **L'override PSR-4 des cores n'etait PAS actif** pendant cette session (classes chargees depuis `vendor/`) : verifier par Reflexion avant de conclure qu'un correctif est sans effet. **🟥 Permis a Paris : la donnee n'existe pas, ce n'est pas un defaut d'API (2026-08-19)** : signale sur `/permis?code_municipalite=75111`. Les arrondissements sont bien traites (Paris 11e a 4 628 parcelles) mais **le departement 75 n'a AUCUN permis**, pas plus que Lyon et Marseille intra-muros - leurs departements en ont pourtant 22 887 et 31 904, repartis sur les autres communes. Couverture : **91,6 % des communes**. ⚠ **3 defauts trouves au passage, dont 2 preexistants** : `/permis` rendait le meme permis **jusqu'a 3 fois** (20 elements pour 12 permis distincts), `/proprietaires` affichait **une commune au hasard** parmi les 14 205 ou le SIREN possede des parcelles (le filtre, lui, etait correct), et `/proprietaires?code_departement=` repondait **500**. Detail : `core-city/CLAUDE.md`. **Endpoints documentes de l'API : tout passe sous 1,8 s sauf `/dvfs` sans filtre (2026-08-19, seconde passe)** : `/plu` par parcelle **6,3 s → 0,20 s**, par code postal **8,7 s → 0,22 s**, par commune **2,9 s → 0,15 s** ; `/parcelles/recherche` sur `lat`/`lon` **187 s → 0,19 s** (l'ancienne formulation finissait en 500 `Disk full` : son tri temporaire consommait les 6,5 Go libres du serveur), sur un departement **89 s → 1,7 s**. Diagnostics et garde-fous : `core-city/CLAUDE.md`. ⚠ **Aucun de ces correctifs ne touche `SearchQueryBuilder` ni `GeographicFilter`**, partages avec le moteur de recherche cadastral de l'application - verifie par grep avant d'ecrire, puis par mesure (**6/6 routes identiques au md5**). ⚠ **Faux positif corrige dans le harness** `.insights/api-foncier-2026-08-18/t_app_dvf.php` : il signalait 2 regressions inexistantes, le tri metier `date_mutation desc` ayant **3 dates en double sur 10** - l'ordre entre elles n'est pas deterministe et les numeros de parcelle n'ont pas tous la meme longueur, donc la taille du payload changeait d'un appel a l'autre ; les features sont desormais triees avant l'empreinte et la reference est refaite (`app_reference_2026-08-19.json`). ⚠ **Reste lent, a traiter en dernier sur demande** : `/dvfs` sans filtre geographique (**20,7 s**). **🟥 `/parcelles/recherche` : 45 s → 0,14 s, et un 500 preexistant sur le mode indicatif (2026-08-19)** : `champs=superficie&limite=1` mettait **45 a 65 s** contre 0,4 s des `limite=3`, avec le MEME SQL - PostgreSQL faisait descendre le `LIMIT` dans le plan interne et parcourait **78 millions de lignes** au lieu des 3 239 parcelles de la commune. Corrige par une barriere d'optimisation dans `HasSearchApi` (cf. `core-city/CLAUDE.md`), **sans effet sur le moteur de recherche web** qui ne partage pas ce trait (verifie : 6/6 routes identiques au md5). Trouve au passage et corrige : **l'exemple officiel du POST repondait 500** (`column tmp.score does not exist`) - toute requete portant un filtre `"indicatif": true` etait cassee depuis toujours. ⚠ **Reste lent, structurel** : un departement entier tient **89 s** et un filtre `lat`/`lon` **154 s**. Harness : `.insights/api-foncier-2026-08-18/t_parcelles_identite.py` (16/17 md5 identiques, la 17e difference etant l'amelioration voulue) et `t_parcelles_perf.py`. ⚠ **Defaut introduit puis corrige le meme jour, signale par l'utilisateur** : la liste de champs mesuree sur `/parcelles/recherche` avait ete appliquee aux 4 endpoints, si bien que la **fiche complete refusait ses propres champs** (`GET /parcelles/{id}?champs=...,dpes,dvfs,zones_plu` → 400). Trois familles distinctes desormais - recherche (11 champs), fiche complete (**26**), apercu SVG (ni champs ni tri). ⚠ **`risques` n'est pas selectionnable** : c'est une sous-cle de `detail`. ⚠ L'**apercu SVG** ne controlait ni son `theme`, ni son `echelle`, ni son `dpi` (retombee silencieuse sur les defauts) - desormais 400, et ses 6 options `afficher_*` etaient documentees « defaut: false » alors qu'elles valent **true**. **L'audit de l'API est termine sur les 4 blocs retenus** (Geocodage, Donnees foncieres, Territoire, Parcelles = 42 endpoints) ; widgets (13) et authentification (11) restent ecartes par decision du 2026-08-19.
- **🟥 Le numero de parcelle du bloc `parcelles` etait decale (2026-08-19)** : signale sur `/api/v1/dpes?code_parcelle=12236000AH0330` - le bloc rendait `"numero": "H033"` au lieu de `330`, l'extraction commencant un caractere trop tot dans la reference cadastrale. Corrige dans **core-city** sur les 6 familles qui exposent ce bloc, en lisant desormais la colonne `fr_parcels.numero` au lieu de decouper la chaine (detail : `core-city/CLAUDE.md`). ⚠ **Ce n'etait pas qu'un defaut d'API** : les comparables DVF de la **fiche parcelle** et du **PDF avis de valeur** consomment le meme bloc via `DvfCollection` - ils affichaient donc aussi un numero faux, et sont repares du meme coup (mesure sur l'appel applicatif reel : `767` au lieu de `W076`). ⚠ **Forme retenue : sans zeros de tete** (`330`, pas `0330`), celle du geocodeur et des PDF - la documentation a ete alignee (ses exemples annoncaient `"0001"`, et 2 d'entre eux portaient une reference de 15 caracteres au lieu de 14). Harness rejouable : **`.insights/api-foncier-2026-08-18/t_bloc_parcelles.py`** - il verifie que `section` et `numero` se relisent bien dans le `code` rendu : **389 parcelles sur 7 sources, 0 incoherence**.
- **🟥 Bloc Territoire de l'API aligne sur le bloc foncier (2026-08-19)** : les **7 endpoints scopes** (`/regions/{code}`, `/regions/{code}/departments`, `/departements/{code}`, `/departements/{code}/municipalities`, `/municipalities/{code}`, `/municipalities/{code}/sections`, `/sections/{c}/{s}`) ignoraient **tous** les parametres - mesure : `/departements/69/municipalities?champs=code&limite=2` rendait **274 communes avec tous leurs champs**. Ils rejouent desormais la normalisation de leur endpoint racine, et une reference inconnue rend un **404 JSON nomme**. **Second defaut** : `champs=superficie` sur `/municipalities` rendait **`[[],[]]`** - seule famille dont la table d'alias ne traduisait pas les noms **francais** documentes ; corrige en entree **et** en sortie. ⚠ **Rupture assumee** : `/municipalities` rendait `surface`/`codeRegion`, il rend desormais `superficie`/`code_region`/`code_departement`, comme les 5 autres familles et comme la documentation l'annonce. Le controle des valeurs et le correctif de tri couvrent aussi ces 6 familles. Tout vit dans **core-city** ; cote site, seules les **annotations Swagger** ont change (12 tris et champs francais qui n'etaient pas documentes). **13/13 endpoints en 200** · `t_tri.py` **87/87** · `t_doc_vs_code.py` **0 ecart sur 13 familles** · moteur cadastral **6/6** · comparables DVF **4/4** · 0 erreur de log · doc regeneree et rebuild reussi.
- **🟥 Les annotations Swagger de ce site sont des COPIES PUBLIEES (2026-08-19)** : `app/Http/Controllers/ApiV1/{Core,CoreCity,CorePerson}/Virtual/` est **regenere** par le `post-autoload-dump` du `composer.json` (`vendor:publish --tag=core-swagger-api|core-city-swagger-api|core-person-swagger-api --force`). **Toute correction faite ici est perdue au prochain `composer dump-autoload` ou `composer update`** - c'est ce qui a efface les 9 alignements du 2026-08-18, dont le CLAUDE.md decrivait pourtant l'existence. ⚠ **Editer la source dans le package** (`core-city/src/Http/Controllers/ApiV1/CoreCity/Virtual/`, idem `core` et `core-person`), puis republier. ⚠ Les copies appartiennent a **`root`** : les manipuler via `docker exec`. ⚠ **Enchainement obligatoire apres modification** : `vendor:publish --tag=... --force` → `l5-swagger:generate` → `docusaurus:generate --force` → `docusaurus:build` (ce dernier exige Node : passer par `bash -lc` pour charger nvm). Fait le 2026-08-19, build reussi vers `public/guide`. ⚠ Une description contenant des **guillemets doubles echappes** casse le parseur d'annotations et **bloque toute la generation** alors que `php -l` passe : utiliser « … ». Harness de non-derive : **`t_doc_vs_code.py --check`** compare les enumerations documentees a ce que le code accepte reellement - **0 ecart** (il a trouve, entre autres, `code_type_bien` documente comme champ de reponse de `/dvfs` alors que le champ rendu est `type_bien`).
- **🟥 Trier via l'API renvoyait une erreur SQL - 3 defauts independants (2026-08-19)** : signale sur `/api/v1/plu?...&champ_tri=zone&ordre_tri=asc`. Derriere ce seul symptome : (1) **`champ_tri` sans `ordre_tri` = 500 sur les 8 familles** (`Undefined array key "order"` - une garde `??` qui ne couvrait pas la branche relisant la valeur) ; (2) **`/plu` : `DISTINCT ON` incompatible avec tout tri** (`42P10`), soit les 5 tris documentes en 500 ; (3) **`/plu` : un nom de fichier dont les 8 premiers chiffres ne forment pas une date faisait tomber la page entiere** (`22008`) - independant du tri, il frappait n'importe quelle page contenant un tel fichier. Les 3 correctifs vivent dans **core-city** et **core-person** (detail : `core-city/CLAUDE.md`) ; **aucun fichier applicatif de ce site n'est touche**. **Verifie** : `t_tri.py` = 8 familles × tous les `champ_tri` documentes × `(defaut)`/`asc`/`desc` → **159 appels, 0 echec** ; non-regression moteur cadastral **6/6** et comparables DVF **4/4**. ⚠ **Lecon de methode** : l'audit des valeurs de la veille testait le REFUS des valeurs invalides mais jamais le SUCCES des valeurs valides dans toutes leurs combinaisons - d'ou ces 500 restes invisibles. ⚠ **`core-person` est desormais dans l'override PSR-4** de ce site et d'`administration` (verifie par Reflexion) : les fichiers de `vendor/rootsud/core-person/` ne sont plus lus. ⚠ **`optimize-autoloader: true` dans `composer.json`** : apres avoir ajoute un override PSR-4, il FAUT relancer `composer dump-autoload` - le **classmap optimise** continue sinon de pointer vers le `vendor/` et l'override reste sans effet (constate : la source n'etait pas chargee malgre l'ajout dans `composer.json`). Le hook `post-autoload-dump` echoue faute de Node (`docusaurus:build`) → utiliser `--no-scripts` puis `php artisan package:discover`.
- **🟥 Audit des VALEURS de parametres de l'API (2026-08-19)** : suite de l'audit du bloc foncier. Une valeur mal formee ne provoquait jamais un refus mais **un 500 portant le message SQL brut**, **une reponse plausible mais fausse** (`intval('abc')` vaut 0, or 0 est une categorie valide : `type_dpe=0` = logement existant, `bati_type=0` = tous les batis) ou **un filtre neutralise** (`floatval('12,5')` vaut 12). Le cas le plus parlant, prouve : `valeur_fonciere_min=0100000` etait lu **32 768 €** au lieu de 100 000, `intval($param, 0)` lisant la base **octale**. Le correctif vit dans les **packages** (`core-city` : nouveau `ApiParamRules` + les 6 `normalize*Params` ; `core-person` : copie locale + refactoring des 2 endpoints scopes ; `core` : `addPaginationParams` et les codes de quota) - **aucun fichier applicatif de ce site n'a ete modifie**, seules les **annotations Swagger** ont change. **74 valeurs mal formees sont desormais refusees en 400 `invalid_parameter`** avec le nom du parametre et la valeur attendue. ⚠ **La contrainte « ne rien casser » a ete tenue par construction ET verifiee** : le controle est pose dans la couche API, jamais dans les traits que l'application partage - moteur de recherche cadastral **6/6** et comparables DVF de la fiche parcelle et du PDF avis de valeur **4/4** identiques apres correctif. ⚠ **Piege de methode a retenir** : la premiere sonde concluait « filtre ignore » quand le NOMBRE de resultats ne bougeait pas → **16 faux positifs**, car un filtre correctement applique rend une page pleine (20/20) exactement comme un filtre ignore. Le test decisif compare les **jeux d'identifiants** de deux valeurs mutuellement exclusives (`t_filtres_discriminants.py`). ⚠ **Quota epuise : 401 → 434** - le handler global du front redirige tout 401 vers `/login`, donc un utilisateur a court de credits etait **deconnecte** au lieu de voir la modale de depassement (detail : `core/CLAUDE.md`). ⚠ **Annotations Swagger alignees sur 20 operations** : `403 = quota depasse` etait documente alors que ce code **n'est jamais renvoye** (403 = scope insuffisant ; le quota sort en 432/433/434/435). **A REGENERER** : `php artisan docusaurus:generate --force` puis `docusaurus:build`. ⚠ **Constate** : les 9 alignements Swagger annonces par la note du 2026-08-18 sur `CTA_GeocoderController.php` **ne sont pas dans le fichier** (0 occurrence de `code_postal`, aucune modification vs HEAD) - l'edition avait ete perdue, le fichier appartenant a `root` ; les codes de reponse sont repris ici, **les enumerations de parametres restent a faire**. ⚠ **Laisse a la decision produit, chiffre** : les endpoints scopes coutent **1 credit** contre **10** pour les endpoints racine, pour la meme donnee - `/sections/{c}/{s}/dpes` rend le meme contenu que `/dpes?code_section=...` au dixieme du prix alors que la doc annonce 10 credits ; et le **bloc Territoire** n'est pas equipe du controle de valeurs. Harness rejouables : **`.insights/api-foncier-2026-08-18/`** (`t_valeurs.py --check` 74/74, `t_filtres_discriminants.py`, `t_params_vides.py` 0 fautif, `t_ab_champs.py` 19 non-regressions, `t_moteur_cadastral.sh` 6/6, `t_app_dvf.php` 4/4, `ETAT.md`).
- **🟥 API v1 : un paramètre laissé vide faisait tomber la réponse à zéro résultat (2026-08-19)** : signalé sur un appel réel - `/api/v1/dpes?numero_dpe=&…&code_parcelle=12236000AH0330` renvoyait `[]` alors que la parcelle porte un DPE. Le correctif vit dans les **packages** (`core-city` : helper `ApiParams::dropEmpty` appelé en tête des 7 `loadX()` ; `core-person` : copie locale dans `HasOccupant` ; `core` : `addPaginationParams` passe de `has('limite')` à `filled('limite')`) - **aucun fichier de ce site n'a été modifié**. Mesure de départ : **39 paramètres fautifs sur 8 familles**, contrôle final **0 sur 9 familles** (~220 paramètres). ⚠ **La requête DVF signalée en même temps est un FAUX POSITIF** : elle porte `vefa=true`, et la parcelle `12236000AH0330` a **1 mutation dont 0 en VEFA** (vérifié en base) - `[]` y est le résultat correct, avant comme après. ⚠ **Reste ouvert, mesuré et documenté sans être appliqué** : `/api/v1/dvfs` sans filtre géographique (17,9 s ; prototype « pilote » à 4,7 ms avec équivalence prouvée, mais 31 des 52 filtres touchent une table jointe → refactoring de `loadDvfs` à part entière), `/sections/{c}/{s}/dpes` 5,96 s, `/municipalities/{code}/plu` 2,20 s. ⚠ **Incohérence de contrat constatée** : quota épuisé → **HTTP 401**, alors que Swagger documente **434** ; un client ne peut pas distinguer « clé invalide » de « crédits épuisés ». Harness rejouables : **`.insights/api-foncier-2026-08-18/`** (`ETAT.md`, `t_params_vides.py` 0 fautif, `t_ab_champs.py` 30/30, `t_ab_scoped.py` 19/19, `t_app_dvf.php` 4/4, `t_moteur_cadastral.sh` 6/6). ⚠ **Piège de harness** : un fragment applicatif qui porte la **date du jour** produit un md5 différent d'un jour à l'autre à taille identique - normaliser les dates avant toute comparaison, sinon on croit à une régression.
- **🟥 API v1 : 14 endpoints de liste renvoyaient des enregistrements vides, et les endpoints scopés ignoraient tous les paramètres (2026-08-18)** : suite de l'audit de l'API (après le bloc « Géocodage »). Les deux défauts vivent dans les **packages** (`core-city` pour 7 traits + `DonneesFoncieresController`, `core-person` pour `HasOccupant`) - **aucun fichier de ce site n'a été modifié** pour ce lot. Ce qu'il faut en retenir ici : (1) `/api/v1/dpes|dvfs|plu|permis|proprietaires|coproprietes|occupants|regions|departements|municipalities|sections|quartiers|epci` répondaient `[[],[]]` sans paramètre `champs`, c'est-à-dire dans le cas nominal ; (2) les 13 endpoints « par code / par commune / par section » ignoraient **en silence** `champs`, `limite`, `champ_tri`, `ordre_tri` et tous les filtres métier - mesuré : `/sections/75108/BO/dpes?champs=numero_dpe&limite=2` renvoyait **74 éléments avec tous leurs champs** contre **2 à un champ** pour `/dpes` avec les mêmes paramètres ; (3) 8 routes `/zonesplu` et `/documentsplu` répondaient **500** (méthodes inexistantes) et ont été retirées. **La contrainte « ne rien casser dans l'application » a été tenue et vérifiée, pas supposée** : les comparables DVF de la fiche parcelle et du PDF avis de valeur (les 2 seuls appelants applicatifs, via `HasMultiParcel` → `DvfCollection`) passent **toujours** un `fields` explicite alors que le correctif ne s'active que sur une liste **vide** - garantie structurelle, doublée d'un A/B md5 **4/4** ; et le moteur de recherche cadastral (`/filter/search`, `/filter/search/count`, `/filter/search/list`, `/opportunity/count`) plus les onglets foncier et estimation de la fiche parcelle ont été rejoués en HTTP avec une **session réelle** : **6/6 réponses identiques au md5**, dont un GeoJSON de 581 Ko. Harness rejouables : **`.insights/api-foncier-2026-08-18/`** (`ETAT.md` = état complet du lot, `t_ab_champs.py` 30/30, `t_ab_scoped.py` 19/19, `t_app_dvf.php` 4/4, `t_moteur_cadastral.sh` 6/6, `t_dvf_plan.php`). ⚠ **Reste ouvert, mesuré** : `/api/v1/dvfs` **sans filtre géographique** met **17,9 s** (déduplication de 26 M de lignes, tri de 762 Mo sur disque - 0,23 s avec `code_municipalite`), `/sections/{c}/{s}/dpes` 5,96 s, `/municipalities/{code}/plu` 2,20 s ; et l'audit des **valeurs de paramètres** de ce bloc (bornes, formats, filtres un par un) reste à faire. ⚠ **Déploiement : tag core-city + tag core-person + `composer update`** ici et sur `administration`.
- **Audit du bloc « Géocodage » de l'API v1 (2026-08-18)** : les 3 endpoints (`/api/v1/geocoder/search`, `/ban/{identifiant_ban}`, `/coordinates`) ont été testés par ~150 appels réels et 6 défauts corrigés - injection SQL par `ordre_tri`, plafond de parcelles global au lieu de par adresse, pagination par curseur produisant doublons et pertes, `lat=48,86` renvoyant une parcelle du Loiret au lieu de Paris, 500 sur `champ_tri`/`ordre_tri` invalides, crédits débités avant validation. Le code corrigé vit dans **core-city** (`GeocoderController` + `HasGeocoder`, détail dans `core-city/CLAUDE.md`) ; côté site, seules les **annotations Swagger** de `app/Http/Controllers/ApiV1/CoreCity/Virtual/CTA_GeocoderController.php` ont changé (9 remplacements : `code_postal` ajouté aux énumérations et défauts, `champ_tri` complété à 7 valeurs, codes de réponse alignés - 404 documenté comme débité, 432/434 pour le quota au lieu d'un 403 jamais renvoyé, 403 réservé au scope insuffisant, plafond de 10 parcelles par adresse, `format` hors énumération documenté comme 400). ⚠ **Le fichier appartient à `root`** : l'éditer via `docker exec`, une écriture depuis l'hôte échoue en `PermissionError`. ⚠ **La documentation en ligne (`docs.devinfoparcelle.fr`) doit être régénérée** : `php artisan docusaurus:generate --force` puis `docusaurus:build`. ⚠ **`/geocoder/search` : 7,9 s → 0,18 s** grâce à l'**ancrage géographique** livré le 2026-08-18 (code postal ou commune reconnus dans le terme, puis classement par similarité sur l'ensemble restreint) - détail, garde-fous et A/B dans `core-city/CLAUDE.md`. ⚠ Un terme **sans ancrage possible** (ni code postal ni commune) est désormais **borné à 5 s puis refusé en 400 `search_too_broad`** avec un conseil actionnable - un terme de 500 caractères passe de plus de 120 s à 7,4 s. ⚠ **Ce cas débite le crédit** (la requête a mobilisé le serveur), contrairement aux erreurs de validation : c'est documenté dans Swagger. Harness rejouables, rapport complet et scripts de mesure : **`.insights/api-geocoder-2026-08-18/`** (`RAPPORT.md`, `t_fixes.py` 30/30, `t_pagination_final.py` 5/5, `quality_bench.py`, `runner.py`). ⚠ **Constaté au passage, NON traité** (à couvrir avec le bloc d'endpoints suivant) : `GET /api/v1/dvfs?limite=1` répond en **20 s** pour un corps vide, et `/api/v1/regions` comme `/api/v1/dpes?limite=1` renvoient une réponse vide en dev - à vérifier avant d'auditer ces blocs.
- **Branding des documents : ce que voit un compte Basique (2026-08-14)** : la personnalisation (logo, couleurs, banniere, couverture) est portee par la variable de plan `ACCESS_ESTIMATE_CUSTOM`, incluse a partir de **Premium**. Cote site, `ActionController::getTheme($whiteMark, plan_access('ACCESS_ESTIMATE_CUSTOM'))` gate deja les 6 generateurs PDF cadastre (avis de valeur, fiche parcelle, plans, plan de situation, fiche annonce, comparateur) et l'onglet Apparence de `/parametres` affiche un teaser d'upgrade. **Ce qui ne l'etait pas et l'est desormais** (correctif dans les packages, cf. `core/CLAUDE.md`) : les **documents de templates** (mandats, compromis, baux, quittance, preavis, cautionnement, bon de visite, informations precontractuelles, lettre de prospection...), la variable de template **`{{user_logo}}`**, et les **exports PDF leads/parcelles**. Un compte qui redescend de Premium a Basique **conserve ses reglages en base** - ils cessent simplement d'etre appliques, et repassent tous a la charte Infoparcelle ; s'il reprend Premium, son branding revient sans ressaisie. ⚠ **Restent volontairement au branding du client quel que soit le plan** (decision produit du 2026-08-14) : le **portail client**, la **page NPS**, la **page d'avis publique** (`BrandingResolver`), la **couleur du widget embarquable**, et les **themes de rendu de plan** (`plan_theme` / `preview_theme`). ⚠ Le gating ne doit JAMAIS passer par `plan_access()` cote package : ce helper depend de `auth()` et de `env('APP_ID')`, tous deux absents du worker de queue - les generateurs appellent `PlanBranding::allows($user, $site)`. Harness rejouables : `.insights/branding-plan-gating-2026-08-14.php` (28/28), `.insights/branding-plan-gating-pdf-2026-08-14.php` (11/11, vrais PDF), `.insights/branding-plan-gating-worker-2026-08-14.php` (6/6, a lancer **depuis `/administration`**). ⚠ Observation de dev, **non traitee** : le logo `invoice` du projet Infoparcelle en base de developpement s'appelle `zefzefze.png` (media_item 548) - c'est lui que sert `{{user_logo}}` en repli ; a verifier en production.
- **🟥 `/historique` en 500 : `$searchMax` assigné DANS une condition court-circuitée (2026-08-07)** : `production.ERROR: Undefined variable $searchMax` (`history/partials/history_action_button.blade.php`). La variable n'était affectée que dans la 4e branche du `if/elseif` (`$count > 0 && ($searchMax = ...) * 0.75`) : dès que `$count` valait **0** - une recherche sans résultat, le cas le plus banal - toutes les branches gardées par `$count > 0` étaient court-circuitées, l'affectation n'avait jamais lieu, et la branche finale `$count >= $searchMax` levait l'erreur → **500 sur toute la page**. **Livré** : `$searchMax` et `$infoThreshold` calculés **avant** la chaîne, et l'ordre des tests aligné sur le miroir JS `searchCounterStatusClass()` (`<= 0` neutre, puis `>= plafond` danger, puis les paliers) - le palier « plafond atteint » reste ainsi atteignable quelle que soit la valeur de `maxResult`, y compris si elle repassait sous 5 000. ⚠ **Le numéro de ligne d'une `ViewException` est celui du fichier COMPILÉ** : Blade retire les commentaires `{{-- --}}`, d'où « ligne 24 » en production pour la ligne 33 de la source - chercher le symbole, pas la ligne. **0 route, 0 migration, 0 fichier de core** pour ce point. Vérifié : le harness **reproduit d'abord l'erreur exacte** avec l'algorithme d'origine, puis 14 valeurs de `$count` rendues via la vraie vue (classes conformes), et **E2E HTTP** sur `POST /history` - une recherche à 0 résultat est fabriquée en base, la page est servie en 200 avec son compteur neutre, la donnée de test est supprimée (`.insights/prod-errors-2026-08-07.php` 27/27, `.insights/prod-errors-e2e-2026-08-07.sh` 18/18). ⚠ Le fragment de ce menu est servi par **`POST /history`** (HistoryController du **core**) et exige `scopes[]` - sans ce paramètre il renvoie une collection vide, donc l'empty state, et la vue fautive n'est jamais rendue (piège de test).
- **🟥 `app/Jobs/` SUPPRIMÉ - un job de site ne s'exécute JAMAIS (2026-08-06)** : erreur de production `Job is incomplete class: {"__PHP_Incomplete_Class_Name":"App\\Jobs\\NotifyClientPortalEvent","tokenId":1,"eventKey":"match_added"…}`. Les **16 workers de queue tournent tous depuis `/var/www/administration`** sur la table **`core.jobs` partagée** par les 12 projets (`QUEUE_CONNECTION=database`, même base `rootsud`) - ce site n'a **aucun worker**. Le namespace `App\Jobs\` y étant inconnu, le job était indésérialisable et échouait avant exécution : **108 échecs du 2026-05-07 au 2026-08-05**, soit 3 mois de notifications portail client jamais envoyées (les 4 observers `LeadObserver`, `LeadProposedMatchObserver`, `AgendaEventObserver`, `UserDocumentObserver` dispatchaient dans le vide). Le job vit désormais dans **`Rootsud\City\User\Jobs\NotifyClientPortalEvent`** (core-city-user) ; le dossier `app/Jobs/` n'existe plus et les observers importent la classe du package. Détail des 2 pièges du contexte worker (URL portail résolue vers le back-office, coupe-circuit `env()` inopérant) et du template SMS servi à un envoi e-mail : `core-city-user/CLAUDE.md`. **Un seul call-site du site modifié** : `notifyClientPortalRequest` passe `Modele::TYPE_EMAIL` à `findForKindScoped` (prévention, résolution inchangée) - ⚠ **en ARGUMENT NOMMÉ (`type:`) volontairement** : ce site peut être déployé avant la montée de version de core-city-user, et en positionnel le 5e argument serait reçu par l'ANCIENNE signature comme `$audience` (`?string`) ; PHP étant en mode coercitif, l'int `0` deviendrait `"0"` **sans TypeError** → `where('audience','0')` → aucun template → **envoi cassé EN SILENCE**. Nommé, un vendor périmé lève `Unknown named parameter $type`, visible dans les logs. **Ordre de déploiement à respecter : core-city-user (tag + `composer update`) AVANT le site.** ⚠ **RÈGLE : ne JAMAIS recréer `app/Jobs/` ici** - tout job dispatché depuis ce site doit être déclaré dans un package `core*`, sinon il échouera silencieusement en production. Vérification : `class_exists()` depuis le vendor d'`administration`. Harness rejouable `.insights/portal-notify-job-2026-08-06.php` (17/17, à lancer **depuis `administration`**) + purge `.insights/purge-failed-portal-notify-2026-08-06.sql` (**À REJOUER EN PROD** ; ⚠ **ne PAS `queue:retry`** : ce sont des notifications vieilles de 1 à 3 mois). ⚠ Le test `PipelineTaskRequirementTypeTest::test_all_findForKindScoped_call_sites_are_fixed` lit désormais le fichier du job par Réflexion ; il échouait **déjà avant ce lot** sur une autre assertion (`$patternA` attend `: 0` alors que le code utilise `: null` depuis un sprint ultérieur) - dette antérieure, non traitée ici.
- **🟥 Deep-link `/acquisition/{id}` : le panneau ne chargeait JAMAIS sans initialisation de la carte (2026-08-05)** : au clic sur une notification système, l'onglet s'ouvre et « mouline » sans jamais afficher le kanban ni la fiche. **Cause** : le bloc `#mapLayerStart` (bootstrap de tous les deep-links) vivait **uniquement dans le callback d'initialisation de Google Maps** (`map.js`, `mapLibLoaded` → `layerDepartment`). Or la carte ne s'initialise pas tant que l'onglet n'est pas visible - mesuré en navigateur : panneau vide au-delà de **21 s** et **zéro requête réseau** émise. Le callback ne se rejouant jamais, l'état était définitif. **Livré** : bloc extrait dans **`consumeMapLayerStart(map)`** (idempotent - le marqueur est retiré au premier passage, donc le chemin nominal reste prioritaire et rien n'est chargé deux fois) + **`armMapLayerStartFallback()`** qui le rejoue sur `visibilitychange` (page redevenue visible → +300 ms) et via un filet à 6 s. ⚠ **Le filet doit être armé dans `$(document).ready`, PAS dans `mapLibLoaded`** : si la carte ne démarre pas, cette fonction n'est pas appelée non plus - l'armer là revenait à ne rien armer (erreur commise puis corrigée). ⚠ La localisation initiale (`data-code`) exige la carte : sans elle on ouvre quand même le menu, seul le recentrage est perdu. **Mesuré après correctif, carte JAMAIS initialisée** : kanban en ~1 s, fiche lead en ~2 s, 0 erreur console. **Lead introuvable = message explicite** : `LoadController::acquisition` expose `mapLayerStartMissingLead` (rendu en `data-missing-lead` par `page_map.blade.php`) → toast « Ce lead n'existe plus ou ne vous est plus accessible. Voici votre pipeline. » au lieu d'un repli muet sur le kanban. Cas fréquent en production : une notification (signature, message portail, lead widget) survit à la suppression du lead qu'elle désigne - il existe en base des notifications orphelines datant de juin. ⚠ **Les harness qui signent des documents émettent de VRAIES notifications** (base + web push) sur le compte de test : `signature-link-guarantee` et `signature-e2e-sign-flow` purgent désormais celles de leurs leads dans leur `finally`, sinon elles survivent au nettoyage et pointent dans le vide. ⚠ Supprimer la ligne en base **ne retire pas** la notification déjà affichée par l'OS. Utilitaire de rejeu : `.insights/replay-portal-notification-2026-08-05.php` (`LEAD=<id>`, remet à zéro le throttle web push de 5/h par couple user×lead).
- **🟥 Document signé : cases manuscrites masquées + visible d'office au portail (2026-08-05)** : deux retours sur un mandat signé au portail. **(1) Les deux régimes de signature cohabitaient sur le PDF** - avant signature, la page « 11. Signature » affichait les cases « Le mandant - … / Précédé de « Bon pour mandat » » alors que le document partait en signature ÉLECTRONIQUE ; après signature, ces cases vides restaient à côté du bloc eIDAS. Le moteur adapte désormais le rendu au régime (`HasModel::applySignatureModeToHtml`, cf. `core-city-user/CLAUDE.md`) ; côté site, `signature_mode` est ajouté aux `$extraVars` de `generateSignableDoc`, `generateBonVisite` et `generateCompromis` - donc **persisté dans `metadata['template_vars']`**, ce qui garantit le même régime à la re-génération. Les 4 re-rendus de `ClientPortalPublicController` (signature mono-partie, multi-parties, et les 2 aperçus `/sign/`) **figent `'electronic'` en dur** : ⚠ **ne JAMAIS y lire `$meta['signature_mode']`**, le provider y écrit la technique de tracé (`draw`/`type`) au moment de signer et le régime est perdu - c'est précisément ce qui faisait réapparaître les cases sur le PDF signé. **(2) Le document signé n'apparaissait pas dans l'espace client** : les 4 chemins qui créent le `User_document` du document signé posaient `metadata.share_with_client` (la notification) mais **pas la colonne `portal_visible`** (défaut `false`), obligeant l'agent à cliquer le toggle œil depuis la fiche lead. `portal_visible => true` ajouté à `ClientPortalPublicController::signProposal` (mono-partie), `submitSignerSignature` (multi-parties), `YousignWebhookController` (eIDAS 2) et `AcquisitionController::markProposalSignedManual` (scan papier) - un exemplaire signé est consultable par le client sans action de l'agent. **Vérifié E2E** (`.insights/signature-e2e-sign-flow-2026-08-05.php`, 14/14, avec extraction du **texte réel des PDF**) : PDF avant signature = 0 case manuscrite + encart « signature électronique » ; PDF après signature = 0 case, bloc eIDAS présent, pas de doublon d'encart ; document signé `portal_visible=true` et listé dans la section Documents du portail. Non-régression : mode papier inchangé (cases conservées, aucun encart), 23 templates non balisés rendus à l'identique.
- **🟥 Signature électronique : l'espace client ne se créait JAMAIS tout seul (2026-08-05)** : un mandat envoyé en signature électronique partait avec le PDF joint **mais sans aucun lien pour signer**. **Cause racine** : `autoCreatePortalTokenForVendor` (`AcquisitionController:3248`) appelle `$this->createPortalToken(...)` alors que le trait **`HasClientPortal` n'est pas monté** sur ce contrôleur (il embarque `HasLeadVisit`, incompatible avec `HasLead`/`HasLeadHub`/`HasProposal`) → `Error: Method ... does not exist` **avalée par son propre `try/catch`** → retour `null` silencieux. `SendModelByMail` n'injecte son bloc « ✍ Signature électronique requise » que si un `portal_url` existe : sans token, l'e-mail part nu, la réponse JSON reste `success: true`, rien n'alerte l'agent. Trace du cas réel (lead 1472) : `12:39:03 autoCreatePortalTokenForVendor failed` → e-mail à `12:39:06` → « Lien portail créé » à `12:39:59`, **à la main par l'agent**. **Livré** : (1) helper **`AcquisitionController::portalHelper()`** (instance anonyme montant le trait, même pattern que `dispatchProspectionDoc`) utilisé par `autoCreatePortalTokenForVendor` **et par `inviteGuarantor`**, qui souffrait du même défaut derrière un `method_exists($this,'createPortalToken')` toujours faux → il retombait sur un fallback créant un token **sans activité de timeline, sans event `portal_token_created`, et sur `user_id` = l'acteur** au lieu du propriétaire du lead ; (2) `dispatchSignableDelivery` cherche le token sur **`$lead->user_id`** (c'est ainsi que `createPortalToken` le pose et que les jobs le relisent) - sur un lead partagé l'ancien lookup sur l'acteur créait un **doublon à chaque génération** et le lien envoyé différait de celui affiché à l'agent ; (3) **garde « zéro envoi muet »** : en signature électronique, si aucun lien n'est transmissible (contact sans e-mail ni téléphone, ou espace client impossible), **rien n'est envoyé** - le document reste généré et téléchargeable, la réponse porte `delivery_blocked` + `delivery_block_message`, une activité épinglée `signature_delivery_blocked` est écrite, et `registry_info` n'annonce plus un envoi qui n'a pas eu lieu. Côté front, un intercepteur **global** `ajaxSuccess.deliveryBlocked` (`phase2-actions.js`) affiche un avertissement au lieu du toast de succès - il couvre **toutes** les modales de génération d'un coup ; (4) `buildSignatureSmsBody` : un template SMS réclamant `{{portal_url}}` sans URL disponible produisait « …à signer en ligne : » (phrase amputée) - il est désormais ignoré au profit des formulations codées, le lien est ajouté si le template n'en porte pas, et la troncature 320 caractères **ne coupe jamais l'URL**. Le reste (filet dans les jobs, invitations multi-parties, exposition des documents multi-parties au portail) vit dans **core-city-user** (voir son CLAUDE.md). **Vérifié E2E** : génération réelle via `POST /acquisition/lead/{id}/mandat/generate` en électronique → **espace client créé dans la même seconde** (contre 56 s manuelles avant), e-mail rejoué avec le mailer `log` = bloc de signature + lien `/client/{token}` + bouton + récapitulatif prix + PDF joint, statut `pending_sign` préservé ; contact sans coordonnée → `delivery_blocked` + PDF généré + **0 job en file** ; rendu du partial portail vérifié sur les 3 régimes (multi+signataire → lien `/sign/`, multi+non-partie → « En attente des autres parties », mono-partie → modale inchangée) ; 5 pages et le fragment de fiche lead en 200. Harness rejouable **29/29** : `.insights/signature-link-guarantee-2026-08-05.php`. ⚠ **Non traités, signalés** : un échec Yousign `abort()` après création laisse le document en attente **sans aucune invitation** (le fallback local est dans la branche `if (!$useYousign)`) - inoffensif tant que `YOUSIGN_ENABLED=false` ; et `SendModelByMail` reconstruit ses variables PDF à la main sans lire `metadata['template_vars']`, donc **le n° de mandat du registre Hoguet n'apparaît pas dans le PDF envoyé par e-mail** alors qu'il figure sur le PDF téléchargé (défaut distinct, antérieur).
- **🟥 Onglet Communications = 2 plans sur 40 : ne JAMAIS y loger seule une donnée que tout le parc doit voir (2026-08-03)** : le panel `data-tab-panel="comm"` est gaté par `$planCrm = plan_access('ACCESS_CRM')` (`lead_detail.blade.php:51`) et **seuls les plans 329 (CRM 79,90) et 332 (CRM 790) ont ACCESS_CRM=1** ; les 38 autres (Basique 327, Premium 328, Essai 286, et TOUS les plans legacy et partenaires 242→300) ont `ACCESS_LEAD=1 / ACCESS_CRM=0`. Les 4 routes `activity` (`routes/web.php:949-952`) sont dans le même cas, donc convertir une donnée en `Lead_activity` ne règle rien non plus. **Règle** : toute information qui doit rester lisible par l'ensemble du parc va dans l'onglet **Pilotage** (jamais gaté, onglet par défaut) ; Communications ne reçoit au mieux qu'un écho. Requête de contrôle : `SELECT bp.id, bp.name_public, max(CASE WHEN pe.label='ACCESS_CRM' THEN bpe.value END) FROM core.billing_plans bp JOIN core.billing_plan_extras bpe ON bpe.billing_plan_id=bp.id JOIN core.project_extras pe ON pe.id=bpe.project_extra_id GROUP BY 1,2;`
- **🟥 Saisie de surface : une écriture par frappe → deadlocks en production (2026-08-03)** : `POST /parcel/surface` (`ParcelUserController::saveSurface`) tombait en `SQLSTATE[40P01] deadlock detected` puis `SQLSTATE[23503]` (violation de clé étrangère). **Cause serveur** (le verrou de `HasParcelUserDetail` ne verrouillait rien) documentée dans `core-city-user/CLAUDE.md`. **Déclencheur côté site** : un même geste utilisateur lançait plusieurs écritures concurrentes sur la même parcelle. (1) `tab-estim.js` persistait sur l'événement **`input`** de `#cardEstimateSurface` via `updateReliabilityDots(..., saveFlag=true)` : **un POST par caractère tapé** (« 120 » = 3 requêtes), alors que le recalcul `postEstimation` juste à côté est débouncé à 750 ms. (2) `tab-info.js` (handler `change`) enchaînait ce même POST **puis** un second POST `/parcel/surface` redondant dans sa branche `else`. (3) `postEstimation` (payload `estimation_*` complet) et `postStrongWeak` (`estimation_swp_*`) partent en plus sur le même événement. **Correctif** : la persistance est regroupée **dans `updateReliabilityDots`** (`tab-info.js`, helper `persistSurfaceReliability`) - un seul envoi 600 ms après la dernière frappe, requête précédente annulée, surface relue au moment de l'envoi (donc la dernière valeur saisie), 600 ms < 750 ms pour que la surface soit écrite avant le recalcul. Corriger là couvre **les 3 appelants persistants** (`tab-info.js:388`, `tab-estim.js:218`, `tab-estim.js:246`) sans les toucher un par un. Le POST redondant de la branche `else` est supprimé. **Mesuré en navigateur** : 5 frappes → **1 requête** portant la dernière valeur ; 4 requêtes réellement simultanées sur une parcelle réelle → **4 × HTTP 200**, une seule ligne par clé en base, 0 erreur console. ⚠ Le correctif front réduit la charge mais ne suffit PAS seul : le worker de queue (`PdfParcelsAvis`) écrit aussi ces tables - c'est le verrou serveur qui protège. ⚠ `app.js` n'a pas de hash en dev + cache `immutable` → **hard-reload** après `npm run dev`.
- **🟥 `/parcel/svg/{id}?neighbors=1` en 500 : géométries découpées non surfaciques (2026-08-03)** : `ParcelController::svg` plantait en production (`Trying to access array offset on float`, `core-city/.../GeojsonToSvg/Renderers/TextRenderer.php:711`). Le défaut est **dans core-city** (`Fr_parcel::getNeighborsParcel` découpe les voisines à la bbox par `ST_Intersection` sans contraindre le type du résultat → LineString / Point / GeometryCollection ; le placement du label de parcelle indexait `coordinates[0]` en supposant un anneau) - correctif complet documenté dans `core-city/CLAUDE.md`. ⚠ Côté site, ce qu'il faut retenir : **la bbox transmise au découpage vient du controller** (SVG intermédiaire 100×100 → `data-min-lon`…, pad 80 %, ajustement au ratio `w/h`), donc le cas ne se reproduit **pas** avec une bbox devinée - un scan doit rejouer ce calcul (`.insights/svg-scan-bbox-controller-2026-08-03.php` : 3 cas fatals sur 3 000 parcelles ; URLs `662160000C0179` et `66002000AH0260`, vérifiées 500 → 200). Les mêmes géométries alimentent `ScrapController` (plans/captures) et les PDF `PdfParcelsPlans` / `PdfParcelsAvis` : le correctif core-city les couvre tous. 3 harness rejouables : `.insights/svg-degenerate-geometry-2026-08-03.php` (13 assertions), `svg-nonregression-2026-08-03.php` (md5 sur 14 rendus), `svg-perf-collectionextract-2026-08-03.php`. ⚠ **Déploiement** : tag core-city + `composer update` (vendor actuellement en 1.0.283).
- **Portail client : fil de conversation complet au lieu de la dernière note (2026-07-27)** : `client_portal/partials/_note.blade.php` n'affichait que **`$notes[0]`** (la note la plus récente du conseiller) et aucun message du client → impossible de relire ses demandes ou de suivre un échange. Le partial est réécrit en **fil de bulles chronologique** (`.cp-thread`, `.cp-thread-item.is-client|.is-agent`, `.cp-thread-bubble/-subject/-text/-meta/-avatar/-status`) alimenté par `sections.notes.thread` (`HasClientPortal::buildNotesThread`, cf. `core-city-user/CLAUDE.md`) : messages du client à droite (fond neutre), réponses du conseiller à gauche (fond accent + liseré), avatars par initiale, dates FR, mention « Transmis à votre conseiller » sur un message encore sans réponse. **Repli** sur l'ancien format `pinned` (rendu comme entrées agent) si un contexte antérieur ne porte pas `thread` → aucune page vide pendant un déploiement. Liste `max-height:460px` (380px < 640px) avec **scroll auto en bas** (script inline : le message le plus récent est visible, l'historique se remonte). Le titre reprend `.cp-section-title` (« Échanges avec votre conseiller »). Styles dans `client_portal.scss` ; `.cp-note*` conservé (plus consommé par ce partial). **PIÈGES** : (a) `$tokenData` ne porte PAS le nom du client → l'initiale vient de `$context['contact']['firstname'|'fullname']` ; (b) `client_portal.css` est buildé par webpack (`npm run dev`) mais `client_portal.js` par `mix.copy` ; (c) pour tester le rendu, un token à `requires_2fa=true` redirige vers `/verify` - désactiver le flag **temporairement** et le restaurer (vérifier après coup).
- **Messages du portail client : card de réponse + état « traité » (2026-07-27)** : un message envoyé depuis l'espace client (`ClientPortalPublicController::sendMessage` → `Lead_activity` note `metadata.source='client_portal'`/`event='message_sent'`, épinglée) créait une entrée de timeline et une notification, mais **aucun état de traitement ni moyen évident de répondre** (le seul chemin était le toggle « Visible client » du champ de note, non découvrable). **(1) Card `_lead_portal_messages.blade.php`** (classes `.lpm-*`) rendue **en haut de l'onglet Communications, au-dessus de la timeline** : expéditeur, sujet, message, zone de réponse, cases de canal, bouton « Marquer comme traité ». Le message reste par ailleurs visible dans la timeline. **(2) Périmètre « en attente »** défini UNE fois dans `app/Helpers/portal_message_helpers.php` (`lead_pending_portal_messages` / `portal_message_is_pending` / `portal_message_is_client_message` / `portal_message_meta`, autoload `composer.json` files) = ni `metadata.replied_at` ni `metadata.handled_at`. La card **lit la relation `activities` déjà eager-loadée** par `detail()` → 0 requête ajoutée au rendu de la fiche. **(3) 2 endpoints** (`planaccess:ACCESS_CRM`, déclarés AVANT le catch-all) : `POST /acquisition/lead/{id}/portal-message/{activityId}/reply` (`replyPortalMessage`) et `.../handled` (`markPortalMessageHandled`). La réponse publie une note `client_visible=true` + `pinned=true` (le boot `Lead_activity::created` déclenche la notification portail) et peut être **doublée par email et/ou SMS** ; chaque canal est best-effort et indépendant (`channels[]` + `errors[]` dans la réponse JSON). **(4) `deliverLeadMessage` extrait de `sendLeadMessage`** (émetteur conseiller + footer Hoguet + `Lead_message` + activité) et partagé par les deux flux - non-régression de la modale « Envoyer un message » vérifiée. **(5) Idempotence** : répondre à un message déjà traité renvoie **409 `already_answered`** (double-clic / deux agents) ; le JS retire alors simplement la card. **(6) Destinataire** = contact du token portail (`metadata.token_id`), repli sur les FK du lead puis le pivot principal - **la card et le contrôleur utilisent la même cascade**, donc le nom affiché est bien celui à qui part la réponse. Cases email/SMS désactivées si le contact n'a pas la coordonnée. **(7) Pastille d'onglet** : `.ld-tab-badge--alert` (rouge) avec le nombre de messages en attente **remplace** le compteur générique de l'onglet Communications, resynchronisée en JS par `Acquisition._dismissPortalMessageCard` (qui remonte via `closest('#acquisitionDetail')` - un harness de test doit donc injecter le fragment ENTIER, pas la seule card). **PIÈGES** : (a) `Lead_activity::metadata` est casté en Collection → toujours passer par `portal_message_meta()` avant tout `array_merge` ; (b) le traitement **désépingle** le message (il reste dans la timeline à sa date) ; (c) rebuild `npm run dev` + **hard-reload** (app.js/app.css sans hash + cache immutable) ; (d) dans un onglet en arrière-plan, `Acquisition.bindEvents()` n'a jamais tourné (Maps ne bootstrappe pas) → l'appeler à la main pour tester les clics.
- **Notes épinglées : fuite au portail client corrigée (2026-07-27)** : `HasClientPortal::buildNotesSection` (core-city-user) exposait au client **toutes** les notes `pinned=true`, en violation de la règle documentée « `pinned` ≠ `client_visible` ». Conséquences observées : (a) le message ENVOYÉ par le client (épinglé à sa réception) lui **revenait affiché comme note du conseiller** dans son propre espace, (b) toute note interne épinglée par l'agent partait au client. Filtre ajouté : `whereRaw("(metadata->>'client_visible')::text IN ('1','true')")` - la diffusion portail redevient strictement opt-in. **Effet de bord assumé** : d'anciennes notes épinglées sans `client_visible` ne sont plus affichées dans le portail (elles n'auraient jamais dû l'être) ; pour en publier une, cocher « Visible client » à la saisie.
- **Timeline lead (onglet Communications) : entrées d'audit opaques nettoyées (2026-07-27)** : la timeline affichait des lignes incompréhensibles - « Statut modifié : 1546939 → 1546940 », « Statut legacy modifié : 1 → 3 », « Statut proposal modifié : 2 → 7 », « Complétée modifié :  → 1 », « Contact acheteur modifié : ∅ → 45 ». **(1) 4 champs retirés de l'audit** car ils doublonnaient EN OPAQUE des entrées déjà lisibles (`Lead.lead_status_id`+`Lead.status` → `status_change` « Estimation → Mandat signé » ; `Lead_task.completed` → `task_completed` ; `Lead_proposal.status` → entrées `document` généré/signé) : 1 662 lignes de bruit sur ~1 900 audits en dev. **(2) Valeurs humanisées** : le trait `LogsAudit` (core-city-user) résout désormais ids et codes en libellés AU MOMENT du changement (« Contact acheteur : ∅ → Mme Elodie Villegas », « Parcelle liée : ∅ → Parcelle 66227000AI0148 », « Type de besoin : ∅ → Bailleur », dates FR, priorités) - figés dans `metadata.old_label`/`new_label`, donc **0 requête au rendu**. **(3) Titre neutre** « Modification : Contact acheteur » (l'ancien `'<Label> modifié'` donnait « Adresse modifié », « Priorité modifié »). **Côté vue** (`acquisition/partials/_lead_timeline_inner.blade.php`, partagé par le rendu initial et `/acquisition/lead/{id}/history`) : lecture `old_label ?? old`, libellé du champ retiré du corps (porté par le titre), `∅` neutralisé. **Côté source** : `HasLeadHub::getLeadFullTimeline` applique `Lead_activity::withoutDeprecatedAudit()` (filet défensif tant que la purge n'a pas tourné sur l'environnement). **Purge + backfill idempotents** : `.insights/clean-audit-timeline-2026-07-27.php` (options `DRY_RUN=1`, `FORCE_RELABEL=champ1,champ2`) - **À REJOUER EN PROD**. **RÈGLE** : n'auditer un champ QUE s'il ne produit pas déjà une entrée de timeline lisible, et couvrir tout champ portant un id/code par `auditLabelForValue` (détail : `core-city-user/CLAUDE.md`).
- **Bloc « Classement équipe » (`/acquisition/kpi`) : loader infini + masquage sans équipe (2026-07-27)** : le bloc restait bloqué sur « Chargement... » pour tout le monde. **Cause racine (core-city-user)** : `getLeaderboardSparkline` bindait 331 080 ids de statuts « won » dans un `whereIn` → dépassement des 65535 paramètres PDO → 500 sur `GET /dashboard/chart/leaderboard` ; `loadLeaderboard` n'ayant aucun `.fail()`, le loader restait à vie (corrigé par une sous-requête, cf. `core-city-user/CLAUDE.md`). **Demande produit** : le bloc ne doit plus apparaître du tout sans équipe ni agence → `DashboardController::hasTeammates($user)` (= `count($user->getTeamUserIds(false)) > 0`, trait core `HasGroups`, exclut l'user courant, try/catch → false) gate (a) le rendu Blade (`@if($showLeaderboard)` autour de `#leaderboardBlock` dans `dashboard/kpi.blade.php`) et (b) l'endpoint `chart('leaderboard')` qui renvoie `[]` sans coéquipier (évite un calcul lourd inutile). **JS** (`dashboard-kpi.js`) : `loadLeaderboard` early-return si `#leaderboardContainer` absent, `.fail()` → `hideLeaderboardBlock()`, et `renderLeaderboard` masque le bloc entier si `data.length <= 1` (un classement d'une seule personne n'a pas de sens) - remplace l'ancien empty-state « Pas de coéquipiers ». E2E curl session réelle : compte solo (214298) → 0 `leaderboardContainer` dans le fragment + endpoint `[]` ; compte avec équipe (user 2) → bloc rendu + 4 entrées ; balises `<div>` équilibrées dans les 2 fragments ; navigateur : loader remplacé par le vrai classement, 0 erreur console. **PIÈGE test** : `AuthController::autologin` valide `token` en **`size:64`** → un token `bin2hex(random_bytes(24))` (48 car.) échoue en validation et redirige **sans consommer le token** (symptôme trompeur : session jamais posée, redirect `/login` sur toutes les requêtes suivantes) - toujours `bin2hex(random_bytes(32))`.
- **Double-clic sur un lead `/acquisition` : plus de fiche en surbrillance bleue (2026-07-27)** : en liste comme en kanban, un simple clic ouvrait bien la fiche mais un **double-clic** la rendait avec TOUT le texte sélectionné (surbrillance de type copier-coller). Cause : le 2ᵉ `mousedown` d'un double-clic déclenche la sélection native du mot survolé sur la card/ligne ; or le 1ᵉʳ clic a déjà lancé `showDetail()` qui remplace `#acquisitionDetailContent` par le HTML de la fiche → la sélection en cours s'étend au nouveau contenu injecté (aucun handler `dblclick` n'existe : le bug est purement natif). **Fix (`acquisition.js::bindEvents`)** : handler délégué `mousedown.acqNoDblSelect` sur `.acquisition-item, .kanban-card` → `e.preventDefault()` **uniquement si `e.detail > 1`**. Le 1ᵉʳ mousedown n'est jamais annulé → le **drag-drop kanban (qui démarre dessus) reste intact** (`dragstart` vérifié en navigateur). **Filet** dans `showDetail()`, juste après `$content.html(data.body)` : si `getSelection()` n'est pas collapsed → `removeAllRanges()` (couvre une sélection amorcée avant l'injection). La sélection/copie de texte **dans** la fiche reste normale (contrôle négatif : double-clic sur un mot de la fiche → mot sélectionné). Vérifié Chrome (double-clic réel liste + kanban → `rangeCount:0`, fiche rendue 7 panels ; mousedown synthétique detail 1/2/3 → false/true/true ; hors périmètre non impacté ; 0 erreur console). JS-only → `npm run dev` + **hard-reload** (`app.js` sans hash + cache immutable).
- **Drag des statuts `/parametres#taches` décalé au zoom navigateur (2026-07-27)** : le drag via `.st-pipe-drag` était parfait en zoom 100 % mais la carte décrochait très fortement du curseur dès 125 %. **Le zoom n'est pas la cause mais le déclencheur** : `bindSortable()` (`setting-pipeline.js`) utilisait le helper par défaut de jQuery UI (`helper:'original'`), qui passe la carte d'origine en `position:absolute` avec pour `offsetParent` le conteneur PerfectScrollbar (`#settingWrapper .scrollbar-container`, `position:relative` + `.ps{overflow:auto!important}`) → ses coordonnées vivent dans l'espace du **contenu scrollé** alors que la souris vit dans l'espace **viewport**. Tant que le panneau ne scrolle pas les deux coïncident (100 % = tout tient à l'écran) ; au-delà le panneau déborde et scrolle pendant le drag → décalage égal au scroll. Aggravé par `jquery-ui 1.13.2 sortable.js::_mouseDrag` qui calcule `this.position` **avant** d'appliquer son auto-scroll puis écrit le `top` obsolète (décalage permanent de `scrollSpeed` = 20 px) ; un scroll molette entre deux `mousemove` n'est jamais compensé. **Fix** : `helper` = **clone rattaché au `<body>`** (`appendTo: document.body`) → `offsetParent` = `<html>` → coordonnées document pures, immunes au scroll et au zoom. Le clone est enveloppé dans `<div class="setting-modern-wrapper st-pipe-drag-helper">` car `.st-doc-section-card` (2ᵉ classe de la carte) est scopé sous `.setting-modern-wrapper` dans `setting.scss` — sans ce wrapper le helper perd fond et bordure. **⚠ 2ᵉ passe (axe X)** : effet de bord du passage au `<body>` — avec `axis:'y'` jQuery UI **n'écrit JAMAIS** `style.left` (`sortable.js` : `if (!o.axis || o.axis !== "y") { helper.style.left = … }`) ; tant que le helper était l'élément d'origine resté dans la colonne flex il gardait sa position horizontale statique, rattaché au `<body>` il tombe à `left:auto` = collé au bord gauche de l'écran. Fix : `left: $item.offset().left` posé dans la fabrique du helper (l'axe X ne bouge pas de toute façon) → X passe de −25 px (le padding du panneau) à **0 px**, Y et largeur inchangés. **RÈGLE COROLLAIRE** : dès qu'on déplace un helper vers le `<body>` avec un `axis` contraint, il FAUT poser à la main la coordonnée de l'axe verrouillé. Bonus : `margin-bottom:12px` retiré de `.st-pipe-card-ghost` (la liste est un flex column avec `gap:.75rem` → la marge faisait sauter les cartes du dessous). Mesuré (harness rejouable `.insights/pipeline-drag-zoom-2026-07-27/`) : −20 px (auto-scroll) et −260 px (scroll molette de 300 px) **avant** → **0 px** dans les 3 scénarios après, ordre après drop identique, 0 helper orphelin. **⚠ défaut latent identique** sur les autres sortables de l'app (tous dans des conteneurs PerfectScrollbar) : `filter.js` `#filterList`, `parcel-tabs/tab-estim-annexes.js`, `parcel-tabs/tab-lead-photo.js`, `acquisition.js::_initTasksSortable` — même correctif si le symptôme remonte. **RÈGLE** : tout nouveau `handleSortable`/`draggable` dont la liste vit dans un `.scrollbar-container` DOIT utiliser un helper cloné rattaché au `<body>`.
- **Agenda pré-prod : 7 bloquants corrigés (2026-07-23)** : (1) **IDOR** - `AgendaController` valide désormais `lead_id` (`validateLeadId` → `Lead::sharedFor`, 422 sinon) + `event_type_id` (exists) dans store/update/updateSingleOccurrence, `updateReport` ne fire les triggers que sur un lead `sharedFor`, et `syncAttendees` filtre les attendees (user ∈ `getTeamUserIds`+soi+owner, contact ∈ contacts de l'acteur - ids forgés silencieusement ignorés). (2) **ESC dialog récurrence** - `_showRecurrenceChoice` (agenda.js) utilise `Swal` importé DIRECTEMENT (`sweetalert2@7.32.4` ajouté au package.json du site, même version que le core → son CSS déjà chargé par app-core.css) : seuls les 2 boutons agissent, tout dismiss (ESC/backdrop) = abandon ; ne JAMAIS revenir à `simpleSwalWithoutAjax` pour un choix destructif (il traite tout dismiss comme le bouton Annuler). (3) **Timezone** - helper `normalizeEventDate` (Carbon parse → `setTimezone(app.timezone)`) appliqué à start/end dans store/update/updateSingleOccurrence : le drag-drop FullCalendar envoie de l'ISO UTC (`toISOString`), le formulaire du local naïf - les deux convergent en heure Paris (testé : 12:30Z → 14:30 stocké). (4) **RSVP préservés** - `syncAttendees` = diff-and-update (les conservés gardent `status`+`is_email_sent`, seuls les nouveaux Users reçoivent l'invitation) ; plus de delete+recreate. (5) **Emails invitation/rappel refondus** (core-city-user, cf. son CLAUDE.md) + **route publique RSVP signée** `GET /agenda/rsvp/{event}/{user}/{status}` (name `agenda.rsvp.signed`, `hasValidSignature` DANS la méthode, throttle 30/min, vue standalone `agenda/rsvp_confirmation.blade.php`, notifie l'organisateur) - déclarée dans la zone publique de web.php (multi-segments donc jamais capturée par `/agenda/{map?}`). (6) **RDV annulés** (`canceled_at` posé par le portail) : exposés `is_canceled` dans events() (gris #9ba3af + `editable:false` + classe `.fc-event-canceled` barrée + pill « Annulé par le client » dans le show modal + suffixe recherche), EXCLUS de checkConflict/stats et des 2 moteurs de rappel. (7) **updateParticipation** : status validé `in [1,2,3]` + cast int de l'eventId (une occurrence virtuelle `"12_20260722"` RSVP désormais la série). **Ops** : la boucle `SendPendingAlerts` (qui porte les rappels agenda) peut mourir silencieusement → checklist + script de ré-armement `.insights/agenda-preprod-ops-2026-07-23.md` (vérifier `core.job_runnings` key=refresh_alert + `core.jobs`). ⚠ le mailgun SANDBOX de dev rejette les destinataires non autorisés (failed_jobs `HttpTransportException`) - le rendu du mail est OK, c'est le transport. **Lot M (2026-07-23, 2ᵉ passe)** : (M3) éditer « toute la série » depuis une occurrence n'écrase plus la DATE de départ de série - `update()` applique l'heure+durée soumises sur la date d'origine (`setTime`) quand `$event->rrule` ; (M4) `expandRecurringEvent` fast-forward le curseur près de rangeStart quand la série est ancienne et sans COUNT (sinon un quotidien > 1 an épuisait le plafond 365 et disparaissait) - même fix dans `AgendaReminder::getNextOccurrenceInWindow` (plafond 730) ; (M5) `searchEvents` exclut les RDV privés d'autrui (`filter` sur `viewerCanSeeDetail`, relation `attendees` eager) - ses propres privés et les publics restent trouvables ; (M7) wording RSVP « X a répondu « Peut-être » ». M9 requalifié négligeable (une seule boucle, CaptImmo archivé) ; M12 quasi faux positif (seul `has_attended` résiduel, colonne jamais lue - UPDATE de confort à rejouer en prod, cf. section 6 de l'audit). **3ᵉ passe (M8+M10)** : (M8) **filet serveur anti-chevauchement** - `store()`/`update()` (non-récurrents, dates changées) répondent 422 `{error:'conflict_confirm_required', conflicts:[...]}` sans `confirm_conflict=1` (helper `findConflictingEvents` factorisé avec l'endpoint check-conflict - il rend une Collection, l'endpoint la json-ise) ; front : le dialog conflit pose `data.confirm_conflict=1`, les error handlers de `_doSaveEvent` ET `updateEventFromDrag` interceptent le 422 → dialog (`_confirmConflictDialog`/`_buildConflictHtml`) → retry ; drag annulé = `refetchEvents()` ; le pré-check couvre désormais les all-day (seuls les récurrents restent skippés, des 2 côtés). (M10a) les attendees **Contact** ajoutés reçoivent une invitation email via le Mailable package `Rootsud\City\User\Mail\AgendaEventClientMail` (queued, template `city-user::mails.agenda_event` SANS RSVP/CTA, .ics, Reply-To conseiller) + `is_email_sent=true` - contact sans email = skip. (M10b) `notifyVisitAttendees` (ClientPortalPublicController) utilise le même Mailable au lieu de `Mail::raw` (queued désormais, tracking + Reply-To conservés, .ics sauf annulation). **4ᵉ passe (revue adversariale 2026-07-23)** : (a) garde contacts de `syncAttendees` élargie au owner de l'event ET au owner du lead (`$allowedContactOwnerIds`) - sinon un ASSIGNÉ sur lead partagé perdait silencieusement le visiteur (les contacts du dossier appartiennent au owner du lead) ; (b) le flux gbv (client-portal-admin.js) pose `confirm_conflict=1` après son dialog + retry auto sur 422 de course ; (c) fast-forward MONTHLY RETIRÉ des 2 moteurs d'expansion (addMonthsNoOverflow non idempotent sur séries du 29/30/31 → dates divergentes + exceptions non matchées ; DAILY/WEEKLY seuls) ; (d) agenda.js : `newEventId = response.event_id || …` (l'ancien `response.event.id` était toujours null → lien tâche visit_request JAMAIS posé depuis la modale) + purge systématique des `_pendingVisit*` ; (e) filet conflit d'update() gaté sur « dates réellement changées » (sinon dialog parasite à chaque édition d'un event en chevauchement assumé) ; (f) `updateSingleOccurrence` : check fin>début + filet conflit ; (g) ICS : DTEND all-day exclusif (jour+1) + DTSTAMP UTC (AgendaIcs + IcsBuilder) ; (h) RÈGLE : tout NOUVEAU flux JS qui POST/PUT `/agenda/events*` doit gérer le 422 `conflict_confirm_required` (dialog → retry avec `confirm_conflict=1`).
- **Entrée/sortie d'agence scope-safe + transfert des leads au départ (2026-07-22, 3ᵉ passe)** : les 5 flux d'`AgencyController` qui remappaient les leads au changement d'appartenance sont alignés sur la règle « le pipeline d'un lead pivote sur SON group_id ». **(1) Core** : `Lead_status::remapLeadsForUser` (core-city-user) filtre désormais `->whereNull('group_id')` - ce helper ne déplace QUE les leads perso, un lead d'agence ne quitte jamais le pipeline de son agence (la dissolution passe par `bulkRemapLeadsToPersonalFromAgency`). **(2) Rejoindre (`acceptJoin` ×2 branches)** : cible = pipeline EFFECTIF du membre via `persoPipelineActive($uid)` - perso actif = override agence (cascade effectiveQuery) → `remapLeadsForUser($uid, null)` (ses leads perso ne bougent pas) ; pas de perso → cible agence. **(3) Quitter/exclure (`leave`/`removeMember` → `remapLeadsAfterLeaving($uid,$projectId,$leftGroupId)`)** : **TROU DE VISIBILITÉ corrigé** - sans transfert, les leads d'agence du partant devenaient INVISIBLES POUR TOUS (le partant ne passe plus le filtre agence de `visibleForUser`, les autres membres ne sont ni owners ni assignés ; l'agence ne donne PAS de visibilité globale). Décision produit : `transferAgencyLeadsToOwner($uid,$gid)` = transfert AUTOMATIQUE des leads `group_id=N` du partant vers l'**owner** de l'agence (`user_id` + `Client_portal_token.user_id`) + **purge des assignations** du partant (`lead_assignees` ET `lead_assignments`) sur tous les leads de l'agence (sinon la branche « partagé avec moi » lui laissait la visibilité) ; ses leads perso restent à lui, remappés sur son pipeline effectif restant. Owner qui part = no-op (leave le bloque déjà : transfert de direction obligatoire). **(4) Archivage d'agence (`archive`)** : même trou - après `bulkRemapLeadsToPersonalFromAgency`, les leads `group_id=N` passent en **`group_id NULL, scope user`** (SEUL cas légitime de mutation du group_id : l'agence cesse d'exister) + rattrapage `remapLeadsAfterLeaving` pour les propriétaires hors membres actifs. **(5) `reactivate` + `resetPersonalTaskSettings`** : cible effective via `persoPipelineActive` ; les leads passés perso à l'archivage RESTENT perso (seuls les nouveaux leads porteront le group_id réactivé). **Harness rejouable** `.insights/harness-agency-entry-exit-2026-07-22.php` (19 checks : remap scope-safe, cible effective, visibilité avant/après transfert owner, purge assignations, token portail, sémantique archive). ⚠ pièges : contrôleur instancié hors route = `new AgencyController(request())` (constructeur exige Request) ; les contacts du lead transféré restent la propriété de l'ex-membre mais restent résolubles via le pivot `lead_contacts` (`resolveLeadContact`).
- **Clic sur un lead depuis l'onglet Leads d'une fiche contact : « il ne se passe rien » (2026-07-27)** : `showLeadFromContact` (contact.js) appelle `showLeftMenu('#menuOnMapContainer-btn-left-menu-acquisition', { lead: id })`. **Racine dans `showLeftMenu` (menu-map.js l.175-178)** : `reloadContent` est calculé UNIQUEMENT d'après les classes de la SIDEBAR (`$(id).hasClass('mm-active') || hasClass('oldActive')`) - or ces classes **ne reflètent pas ce qui occupe réellement `#colMenu`** : `showMap(unselectMenu)` (l.976) pose `oldActive` sur **TOUS** les `<li>` de la sidebar. Donc dès qu'on est passé en carte plein écran (ou qu'on vient du menu Acquisition), l'item acquisition porte `oldActive`/`mm-active` alors que `#colMenu` affiche la fiche contact → `reloadContent = false` → la branche `data.lead > 0` faisait **UNIQUEMENT le `history.pushState`** puis `callback` (undefined ici) : l'URL passait à `/acquisition/{id}` mais **ni le fragment ni `Acquisition.showDetail` n'étaient appelés** → écran inchangé. D'où le « rare » : dépend de l'historique de navigation, pas du lead cliqué. **Fix (2 points, dans le case acquisition de `showLeftMenu` → bénéficie à TOUS les appelants)** : (1) `if(!reloadContent && $('#acquisitionList').length <= 0) reloadContent = true;` - la présence réelle de la liste dans le DOM prime sur les classes de la sidebar (corrige AUSSI l'ouverture du menu Acquisition **sans lead** depuis un autre menu, qui souffrait du même trou) ; (2) dans la branche « pas de reload » (liste bien affichée), appeler `Acquisition.showDetail(data.lead, true)` - sinon un `showLeftMenu(acquisition, {lead})` alors que la liste est déjà là ne faisait rien non plus. `window.spaNavigate` (app.js) contournait déjà le problème en amont via `$('#acquisitionList').length > 0 → showDetail` : le trou ne touchait que les appelants directs de `showLeftMenu` (fiche contact). E2E : reproduit (URL `/acquisition/558` mais `#acquisitionDetail` absent et les 49 cartes contact toujours à l'écran) puis corrigé sur les 3 états de l'item (`mm-active`, `oldActive`, sans classe), + liste déjà affichée, + navigation lead→lead, + menu sans lead, + non-régression `spaNavigate`/retour arrière, 0 erreur console.
- **Wizard pipeline → « Changer » de mode : la modale `#agencyChooseModeModal` ne s'ouvrait pas au 2ᵉ passage (2026-07-27)** : le bouton `#pipeWizardChangeMode` (et `#pipeWizardOpenModeModal`) faisait `hideModal('#modalPipelineWizard')` puis `setTimeout(showModal(cible), 300)`. **Racine** : `modal('hide')` de Bootstrap est un **NO-OP SILENCIEUX** tant que la modale est en transition (`if (this._isTransitioning || !this._isShown) return`) - donc si le clic tombe pendant l'animation d'ouverture du wizard (ou si le DOM est occupé, typiquement juste après un reload du fragment /parametres qui pèse ~1,5 Mo), le wizard **reste ouvert** ; comme les 2 modales sont `.modal-side-right` au **même z-index (3000)** et que le wizard est **APRÈS** la cible dans le DOM (déplacé au `<body>` par `pipeWizDedupeKeepFresh`), la modale de mode **s'ouvre bel et bien mais DERRIÈRE** → symptôme « elle ne s'ouvre pas » (constaté en test : `mode.show=true` + `display:block` + 2 backdrops, invisible à l'écran). Variante encore plus vicieuse : le clic arrive **avant** que le wizard ait fini de s'ouvrir → tester « est-il visible ? » ne suffit pas (il ne l'est pas encore), il s'affiche ensuite par-dessus la cible. **Fix** (`setting-pipeline.js`) : nouvelle fonction **`pipeWizCloseThenOpen(target)`** = fermeture **DÉTERMINISTE** via `window.resetModal` (retire `fade` → hide synchrone, puis `dispose` → l'animation en cours ne peut plus ré-afficher le wizard) + purge backdrops/`modal-open`, PUIS `showModal(cible)`. Plus aucun délai fixe. La réouverture du wizard reste normale (le data-api recrée une instance propre). **Masquer sans `dispose` ne marche PAS** (vérifié : Bootstrap ré-applique `show`/`display:block` en finissant son animation). **2 correctifs joints** : (a) **double reload** - un changement de mode déclenchait DEUX `reloadSettingsTab` (le `onModalHidden` d'`applyMode` + le handler `hidden.bs.modal.pipeWizard`) → 2 `loadPostContentMenu` concurrents réinjectaient `#colMenu` et laissaient des copies dupliquées du wizard ; `reloadSettingsTab` (setting-agency.js) est désormais **ré-entrant-safe** (verrou `window.__settingsTabReloading` relâché dans le callback + filet 8 s) - mesuré : 3 appels concurrents → 1 seule requête ; (b) **callback résiduel** - `onModalHidden` (core) pose un handler PERSISTANT : le callback d'`applyMode` rejouait à chaque fermeture ultérieure de la modale (reload parasite, ou réouverture spontanée de `#agencyJoinTokenModal` / du wizard de création si le mode précédent était « rejoindre »/« creer ») → neutralisé en one-shot **via le helper du core** (`onModalHidden(sel, function(){})`), car un `$(sel).off()` depuis le bundle du site ne voit PAS ce handler (stores d'events non partagés entre bundles - vérifié `$._data` vide). **Effet de bord connu et bénin** : si le clic « Changer » tombe pendant l'animation d'ouverture, le fallback `emulateTransitionEnd` de Bootstrap se déclenche après le `dispose` et loggue un `TypeError: ... focus of null` (aucune conséquence fonctionnelle ; absent du cas nominal). E2E : cycles répétés à 60/100/600/900/1200 ms → wizard fermé + modale mode visible + **1 seul backdrop** à chaque fois, réouverture du wizard OK, 0 copie dupliquée.
- **Wizard pipeline : réassignation des leads à l'application d'un preset (2026-07-22, 2ᵉ passe)** : appliquer un modèle via `#modalPipelineWizard` (`st-pipe-wizard-trigger`) ne perd plus jamais de leads et l'utilisateur choisit leurs colonnes de destination. **(1) Endpoint `POST /parametres/pipeline/preset-impact`** (`SettingController::presetImpact`, entity-aware, mêmes params de résolution que apply-preset : mandate_types/automation_level/scenario_slug) → `{total_leads, orphan_leads, statuses:[{id,name,color,category,lead_count,suggested_slug}], new_statuses:[{slug,name,color,category,base_status_id}]}` ; comptages = leads DU PÉRIMÈTRE (agence = `group_id=N` tous membres ; perso = `user_id` + `group_id NULL`) ; suggestion = même base+catégorie > même base > même catégorie > 1re active ; `new_statuses` via **`PipelinePresetService::previewPresetStatuses`** (résolution factorisée `resolveEffectivePreset`, 0 écriture). **(2) JS** (`setting-pipeline.js::applyPreset`) : POST preset-impact AVANT apply ; si des leads occupent le pipeline actuel → **`openWizardLeadMappingDialog`** (overlay z-index 12000 au-dessus du wizard, une ligne par statut occupé : dot couleur + nom + compteur + select des nouveaux statuts pré-sélectionné sur la suggestion, note « N lead(s) supplémentaires repositionnés automatiquement » si orphelins) → confirm = POST apply-preset avec `lead_mapping {oldStatusId: slug}` ; annulation = rien d'appliqué ; erreur impact = dégradé comportement historique (remap auto). `doApplyPreset` extrait d'applyPreset ; toast enrichi « N lead(s) repositionnés ». **(3) `PipelinePresetService::apply`** (10ᵉ param `?array $leadMapping`) : le step 5 remplace `Lead_status::remapLeadsForUser` par **`remapPerimeterLeads($groupId,$resolvedUserId,$slugMap,$leadMapping)`** - mapping explicite d'abord (old lead_status_id → slug→id créé, `status` base seulement si non-null), puis **filet automatique** sur TOUT lead du périmètre hors nouveau pipeline (par base_status_id, fallback 1re active, chunkById). **Corrige 3 bugs de l'ancien remap** : les leads des AUTRES membres d'agence restaient sur les statuts archivés (remapLeadsForUser ne traitait que le user appelant) ; les leads d'agence du user étaient détournés vers le perso (et inversement) ; `remapLeadsForUser` reste utilisé par les flux entrée/sortie d'agence (sémantique différente, non touché). **E2E** : dialog réel vérifié navigateur (comptages périmètre, 7 options, suggestion présélectionnée, annulation propre) + apply complet tinker sur cobaye 5584 restauré (mapping explicite Qualifié→Vendu appliqué, non-mappés → filet, lead d'agence INTACT, 0 lead perso sur statut archivé) ; contrôles globaux : 0 lead sur statut archivé, 0 lead d'agence sur statut perso. ⚠ piège E2E : la session ngrok est PARTAGÉE entre instances - vérifier `meta[user-id]` avant tout test destructif navigateur.
- **Pipeline /parametres#taches : drag statuts + archivage avec réassignation réparés (2026-07-22)** : (1) **Drag** - `bindSortable()` (setting-pipeline.js) appelait `window.Sortable.create` (API SortableJS) alors que SortableJS n'est chargé NULLE PART (ni package.json ni CDN) → return silencieux, drag mort depuis la refonte 2026-05-19. Réécrit sur **jQuery UI** via `window.handleSortable($list, {handle:'.st-pipe-drag', items:'> .st-pipe-card', axis:'y', placeholder:'st-pipe-card-ghost', forcePlaceholderSize:true, update:…})` (pattern `_initTasksSortable`) + style `.st-pipe-card-ghost` (_setting-pipeline.scss, `visibility:visible !important` car jQuery UI pose visibility:hidden inline sur le placeholder). (2) **Archivage avec leads** - le JS testait `xhr.responseJSON.requires_reassign` (clé inexistante, le backend envoie `error='reassign_required'`) et n'offrait AUCUN sélecteur → impasse. Nouveau flux 2 temps : DELETE sans param → si 422 `reassign_required` (enrichi côté `SettingController::archiveStatus` de `statuses:[{id,name}]` = candidats du même périmètre), `openReassignDialog()` (overlay autonome styles inline, fallback DOM) → re-DELETE avec `reassign_to_id`. Wording swal corrigé (plus de promesse de repositionnement automatique). (3) **Bug cross-scope CoW dans archiveStatus** : après `copyOnWriteIfCrossScope`, `$leadCount`/remap/triggers utilisaient `$id` ORIGINAL (agence) alors que `$status` = clone perso → on aurait remappé les leads des AUTRES membres vers un statut perso. Corrigé : `$statusId = $status->id` PARTOUT ; `status` de base du lead mis à jour SEULEMENT si `base_status_id !== null`. (4) **CoW ne détourne plus les leads d'agence** : les 3 remaps de `ensurePersoPipelineFromAgencyLocked` (filet backfill + remap principal + chunkById) filtrent `->whereNull('group_id')` - un lead d'agence reste sur le pipeline de son agence (règle « Verrouillage agence sur leads »). **Réparation données** : script rejouable `.insights/fix-agency-leads-on-perso-status-2026-07-22.php` (26 leads d'agence 215/221 réalignés en dev via base_status_id, résiduel 0 - À REJOUER EN PROD ; ⚠ utilise une SOUS-REQUÊTE whereIn, un pluck() dépasse les 65535 params PG). **Explication du « 3 vs 1 »** : le comptage backend était exact (3 leads en base sur le statut perso), le kanban n'en montrait qu'1 car les 2 leads d'agence détournés étaient orphelins dans le pipeline agence → projetés 1re colonne. E2E validé (drag réel ×2 persisté, dialog réassignation lead remappé, statut vide direct, 0 lead sur statut archivé dans TOUTE la base, 0 erreur console).
- **Menu contextuel parcelle : « Estimer ce bien » + « Planifier une visite » réparés (2026-07-22)** : les 2 hooks appelés par le clic droit carte (`core-city/maps_sector.js::askAddParcelToSelection`, NON modifié) sont réécrits dans `lead-actions.js`. **(1) `window.openEstimation(cadastreId)`** : `showParcelCard` + poll 100ms qui attend la nav `#parcelCardNav a.btn[href="#tab-estim"]:visible` ET le signal DOM **`#parcelCard #parcelCardContent.ps`** (la classe PerfectScrollbar est posée dans le MÊME bloc synchrone que `initCommonFeatures()` qui binde le `shown.bs.tab` du nav) puis `removeClass('active')` sur les frères + `showTab($tab)`. **PIÈGES** : (a) `#parcelCardNav` n'est PAS un `.nav` Bootstrap → `tab('show')` active le panel mais ne retire pas l'`active` du bouton frère (retirer explicitement, pattern tab-init.js:883) ; (b) cliquer AVANT que `onTabShown('#parcelCardNav')` soit bindé active l'onglet SANS déclencher son chargement AJAX (`handleTabChange`) - et ce binding est fait par la jQuery du CORE (global `onTabShown`) donc **invisible depuis `$._data` du bundle app** → seul le signal DOM `.ps` est fiable cross-bundle. **(2) `window.openAgendaCreate(cadastreId, address)`** : l'ancien fallback (`$.get('/agenda/event-form')` + `initInlineAgendaForm` = doublon d'`#agendaEventModal` + `$modal.show()` jQuery → rien ne s'affichait) est SUPPRIMÉ ; nouveau flux = résolution adresse via `GET /acquisition/lead/prefill` (fallback silencieux chip cadastre seul) → pendings `pendingAgendaTitle` + `pendingAgendaEventType='visite'` + `pendingAgendaParcel={address,cadastre,lat,lng}` → **`Agenda.ensureFormBindings()`** (nouvelle méthode agenda.js, idempotente flag `_formBindingsDone` : déplace la modale vers body + `bindEvents`/`bindAttendees`/`bindAddressAutocomplete`, tous form-only sans dépendance FullCalendar ; `init()` l'appelle aussi) → `Agenda.showEventForm(null, today)`. Le handler `pendingAgendaParcel` passe désormais le cadastre au chip (`renderAgendaAddrChip(address, cadastre)` → chip « adresse • cadastre ») + `initFormMap(lat,lng)`. **(3) `leadPrefill` : champ `address` réparé** - l'ancien `method_exists($parcel,'getCurrentAddress')` était du code mort (méthode inexistante → address TOUJOURS null) ; remplacé par `$parcel->getAddresses()[0]['label']` (BAN/BDNB trié par fiabilité). La route `/agenda/event-form` + `AgendaController::eventForm` restent (orphelines, deletion-safe). E2E validé : modal + type visite + gradient + chip complet + mini-map + save réel en BDD (event supprimé), onglet estim seul actif + contenu chargé, non-régression create lead / menu agenda complet après context menu (1 seule modale, 0 double handler) / pendings lead. 3 fichiers : lead-actions.js, agenda.js, AcquisitionController. **(4) « Localiser l'annonce ici » : resync GLOBAL des contrôles `.parcel-locate` (2026-07-22, 2ᵉ passe)** - le clic droit localisait bien l'annonce mais les boutons « Lier »/« Liée » du top-10 (style row) et des résultats de recherche (style compact `pl-compact-link`) restaient périmés (les callbacks `onLocated`/`onUnlocated` ne repeignent que l'ITEM cliqué, et le flux clic droit n'a aucun item). Fix : helper `refreshAllLocateControls(flxId)` (lead-actions.js) = `window.refreshLocateStates($(document), flxId)` (1 POST location-states pour TOUS les contrôles visibles) appelé après succès dans `locateAdvertThenAskLead`, `createLeadFromLocatedParcel` (lead_id propagé aux duplicatas) et `unlocateAdvertConfirm`. Couvre aussi une même parcelle présente à la fois dans le top-10 ET #resultList. E2E harness DOM (2 contrôles row+compact même cadastre, vrais endpoints) : locate clic droit → les 2 passent « Liée » + location_id ; délier via un contrôle → les 2 repassent « Lier » ; BDD nettoyée.
- **Verrou création d'agence selon le mode pro (2026-07-22)** : la création d'agence (`POST /parametres/agence/creer`) et la transformation équipe→agence (`POST /parametres/equipe/{id}/transformer-agence`) sont désormais gardées serveur par `AgencyController::assertAgencyCreationAllowed()` - autorisé si `isHoguetCompliant()` OU compte jamais configuré (aucune notice perso + aucune agence, cas onboarding) OU **intention de transition** en session (`agency_create_intent_at`, posée par `updateMode('creer')`, TTL 15 min, consommée à l'usage) ; sinon **422** message « mode pro immobilier requis ». ⚠ PIÈGE : `updateMode('creer')` n'écrit RIEN en base → une garde naïve `!isHoguetCompliant()` casserait le flux légitime « prestataire/particulier choisit le mode Créer une agence » (l'user est encore non-Hoguet au moment du POST create) - d'où le mécanisme d'intention. L'onboarding (`welcome-onboarding.js::confirmMode`) POST désormais `/parametres/mode {mode:creer}` en fire-and-forget pour poser l'intention (il le sautait volontairement avant). UI : bouton « Transformer en agence » (fiche équipe, `account.blade.php`) gaté `isHoguetCompliant()` ; wizard `_modal_create_wizard` porte `data-aw-hoguet` → pour un non-Hoguet `openCreateStructureModal(false)` **saute l'étape 1** (ouvre directement « Créer une équipe ») et la carte agence de l'étape 1 (`data-action="aw-open-mode-modal"`, handler `click.aw-open-mode` setting-agency.js) ferme le wizard et ouvre `agencyChooseModeModal` (le vrai parcours) ; bouton page équipe « Nouvelle équipe » pour non-Hoguet. PAS de OWNER_LOCK dans create() (multi-agences supporté). NB : le middleware `planaccess:ACCESS_TEAM` reste en amont (432 si plan sans équipe). E2E validé (compte demo@prestataire.fr 214298 + flux mode=creer complet + garde 422 + compte vierge autorisé).
- **Rafraîchissement auto sur /compte/* après actions agence/équipe (fix 2026-07-22)** : `reloadSettingsTab()` (setting-agency.js) testait seulement `typeof loadPostContentMenu === 'function'` pour choisir la branche SPA - or **app.js est chargé sur TOUTES les pages, /compte/* inclus** → la fonction existe partout, mais le split-panel `#colMenu` n'existe QUE sur les pages map → sur `/compte/profil`/`/compte/equipe`, l'injection partait dans le vide (no-op silencieux) et **rien ne se mettait à jour** après : changement de mode via `agencyChooseModeModal` (handler réel = `click.ag-confirm-mode` → `applyMode()` → reloadSettingsTab, PAS le `doApplyMode` qui reload - il y a DEUX flux parallèles), quitter/archiver/réactiver une agence, retirer un membre, créer une équipe. **Fix** : la branche SPA exige `document.getElementById('colMenu')` ; sinon `window.location.reload()`. Idem branche création d'AGENCE du `submit.aw-create` : hors split-panel → `window.location.href = '/parametres?entity=group:N'` (navigation pleine, cohérent avec le flux transformer-agence). E2E validé : mode presta↔independant sur /compte/profil (reload auto + bannière à jour), création équipe (reload + visible), création agence (navigation /parametres?entity), archivage (reload), suppression équipe (redirect core pré-existant OK), non-régression /parametres (branche SPA conservée, 0 full reload). ⚠ Notes produit pré-existantes découvertes : (1) le `deleteGroup` du core ne supprime QUE le `Group_access` de l'user (la ligne `groups` reste orpheline en base - comportement legacy) ; (2) une agence ARCHIVÉE disparaît de /compte/equipe (`allAgenciesActive()` filtre `active()`) et sa fiche `/compte/equipe/{id}` redirige → **aucun point d'entrée UI pour la réactiver** (`data-action="reactivate-agency"` n'existe que dans `compte/agence/manage.blade.php`, devenu inaccessible) - bug produit distinct, non traité.
- **Fix « Quitter l'agence » - ModelNotFoundException Group_user corrigée (2026-07-22)** : `POST /parametres/agence/{id}/quitter` crashait en `No query results for model [Group_user]`. **Cause racine** : la FK `group_access_links_group_access_id_foreign` n'a PAS de ON DELETE CASCADE → un lien d'invitation consommé (créé par le flux équipe legacy du core avec `group_access_id`) faisait échouer le `Group_access::delete()` de `leave()` en 23503 APRÈS que `left_at` soit posé → état incohérent (membership terminé mais accès conservé) → l'agence restait visible dans /compte/equipe et le clic suivant sur Quitter tombait sur `firstOrFail()` → 404. **Fix** (`AgencyController`) : (1) helper `purgeGroupAccess($userId,$groupId)` qui supprime les `Group_access_link` enfants AVANT les `Group_access` ; (2) `leave()` idempotent — `first()` au lieu de `firstOrFail()`, nettoyage de l'accès résiduel même si `left_at` déjà posé, 422 message clair si plus membre du tout, le tout en transaction `DB::connection('pgsql_root')` ; (3) même durcissement sur `removeMember()` et la branche rétrogradation de `updateRole()`. **RÈGLE** : tout delete de `Group_access` DOIT passer par `purgeGroupAccess` (jamais un `->delete()` direct). ⚠ Les transactions multi-modèles Group_* doivent être sur la connexion `pgsql_root` (le `DB::transaction()` par défaut du site couvre `pgsql`/public — `transferOwnership` a encore ce défaut latent). E2E validé (session réelle : 1er appel 200 + purge DB access 105/link 40, 2e appel 422 propre, agence disparue de la liste). PHP-only, 0 migration/rebuild.
- **Fix null-safe fiche annonce - erreur prod « seller on null » corrigée (2026-07-21)** : l'erreur prod `Attempt to read property "name" on null` (advert/card.blade.php, closure `groupBy` sur `$item->seller->name`) existait aussi en dev et est corrigée. Une annonce peut n'avoir NI ligne `propertie_advert_sellers` NI ligne `propertie_advert_prices` (adverts dev 10633313/6106658/1448576/839090 = repro). Gardes `?? null`/`?? 0` posées sur : `card.blade.php` (seller->name/siren l.5/18/19, price->scope l.86, price->latest l.377/533, price->currency l.534, explode currentSeller l.27), `card_price_v2.blade.php` (currency l.4), `advert_list_ads_item.blade.php` (l.376/380), `advert_list_ads_item_thumb.blade.php` (l.287), `parcel/card_annonces.blade.php` (l.340/644) + `$p->contact?->fullname` dans `_lead_offers_timeline`/`_lead_applications_timeline`. Vendeur absent → groupe clé `'!###!0|1'`, affiché « Anonyme » (fallback contact déjà en place). **RÈGLE** : tout accès `->seller->x`, `->price->x->y` ou `->contact->x` dans une vue DOIT être gardé (`??`, `isset()`, `?->`) - ces relations hasOne/belongsTo peuvent être absentes sur les enregistrements dégradés. E2E validé (fragments 200 : card dégradée + normale, liste annonces, parcel tab annonces, kanban, 2 fiches lead ; 0 nouvelle erreur log). Blade-only, `view:clear` suffit (pas de rebuild).
- **Grille V2 — pièges (2026-07-10)** : (1) le check boolean d'une variable non incluse renvoie **432** (pas 435) — les handlers front ouvrent déjà la modale plan sur 432 ; (2) `CheckPlanAccess`/`hasPlanVariable` instancient un contrôleur anonyme core → **`new class(request()) extends Controller`** (le constructeur exige Request) ; (3) tout nouveau module DOIT être gaté par UNE variable + middleware/`plan_access()` — la matrice = les groupes `planaccess:` de web.php ; (4) les **paiements des comptes démo expirent** (end_at) → si tous les checks retournent 0, prolonger `billing_plan_payments.end_at` ; (5) `document_kinds_forbidden_for`/templates suivent les capacités → un Premium ne voit AUCUN template (voulu) ; (6) packs Yousign 337/338 ACTIFS — driver réel `YouSignProvider` branché le 2026-07-10 (voir puce « Signature Yousign eIDAS niv. 2 » ci-dessous) ; (7) prix packs SMS + mapping plans partenaires = placeholders à valider avant prod ; (8) en prod, rejouer les 3 scripts `.insights/sql-tarifs-v2-*.sql` (ou saisir via /administration) puis `route:clear`+`config:clear`. ; (9) **`Billing_plan::extra()` ne filtre PAS `archived`** → désactiver une billing_plan_extra par `value='0'`, jamais par archived=1 (sinon elle reste active moteur + cartes vitrine) ; (10) vitrine /tarifs = 100% data-driven (cartes via show_in_card + parent des plans, tableau via config vitrine comparison_features) — promo test « API PROMO TEST » archivée (faussait le prix CRM), badge annuel = « 2 mois offerts ». ; (11bis) 9 extras **DISPLAY-ONLY** `CRM_LEGAL_DOCS/CRM_MANDATE_REGISTRY/CRM_MULTI_SIGNATURE/CRM_MATCHING_OFFERS/CRM_TEMPLATES/CRM_FEES/CRM_REVIEWS/CRM_SUPPORT/YOUSIGN_LEVEL2` (badge Bientôt) enrichissent la carte tarif CRM — aucun gate de code ne les lit, l'accès reste ACCESS_CRM ; (12) 4 routes du package **core-advert** (`/advert/propertie/*`) gatées dans le CONTRÔLEUR via `assertPlanAllowsAdvert` (hasPlanVariable fail-open — pas l'alias middleware du site, qui n'existe pas sur CaptImmo) ; (13) `OpportunityController::count` ajouté (la route pointait sur une méthode inexistante = 500 depuis toujours) + branche réponse search robuste au scalaire ; (14) **AUDIT PACKS 2026-07-10 - BUG CRITIQUE corrigé dans le CORE** : un pack acheté via Payplug crée un payment SANS subscriber (`createSubscriber` gaté sur `plan->subscription`) et `HasPage::getPreferedPayment` l'écartait (`-7`) → crédits AFFICHÉS mais JAMAIS consommables. Fix core (one-shot non-trial sans subscriber éligible, détail `core/CLAUDE.md`). ⚠ tout harness qui simule un achat de pack DOIT créer le payment avec `subscriber_id=null` (état réel) - cf. `.insights/yousign-2026-07-10/harness_nosub.php` ; les 4 harness packs (`harness_yousign` 31 checks / `harness_credits` 9 / `harness_order` cumul+ordre / `harness_nosub` sans subscriber) sont rejouables. Validé aussi : cumul CRM+pack 99+100=199, conso = quota mensuel d'abonnement AVANT pack (prix HT plus élevé = préféré), pricing end_at racine (SMS→2076, Yousign→+1 an), 5 pages /paiement/33X (dropdown famille, quantité/promo masquées, pas de redirect), packs ABSENTS de plansPick (`getUserPlan` filtre `subscription != 0`)/vitrine/abonnement hors #recharges. Reste avant prod : 1 achat réel sandbox Payplug (webhook WAITING→PAID) ; `/reviews` GET HTML → redirect `/parametres#avis` (la vue reviews.index n'a jamais existé, fix 2026-07-10 ; le JSON reste la voie normale) ; (11) **FILTER_INDICATIVE_LIMIT supprimée** (2026-07-10) : indicateurs de pertinence illimités tous plans, limite en dur `HasSearch::$MAX_INDICATIVE_PARAMS` (=20) passée directement à `convertAdvertToFilters` dans LoadController+AdvertController — ne pas réintroduire la variable.
- **Scopes API + sécurisation des tokens widget (2026-06-28)** : le token API auto-créé pour un widget est désormais restreint au scope `["widget"]` (`WidgetController::ensureWidgetApiKey`) → s'il est dérobé dans le code public du site de l'agence, il **ne peut plus appeler les routes qui consomment des crédits** (cadastre/person/account → 403 `insufficient_scope`). Mécanisme générique côté **core** (middleware `CheckApiScope` default-deny + `config/api_scopes.php` + UI cases à cocher `/compte/api`, voir `core/CLAUDE.md`). Côté CE site : (1) `ensureWidgetApiKey` pose `scopes=['widget']` ; (2) `renderConfigPage` + `hostedPage` sélectionnent en PRIORITÉ un token **widget-capable** (widget-only > `*`) pour le snippet/page PUBLIC (jamais un token total/cadastre exposé en clair) — créent un token widget si l'user n'en a aucun de capable. **PIÈGE vendor** : `core-person` n'est PAS dans l'override PSR-4 du `composer.json` (≠ `core`/`core-city`/`core-advert`/`core-city-user`) → ses routes/provider viennent de la **copie vendor figée** `vendor/rootsud/core-person/` (root-only). Le tag `apiscope:person` y a été appliqué directement (le default-deny protège déjà les routes person même sans tag ; le tag ne sert qu'à AUTORISER un futur token `person`). Idem pour administration/rs_captimmo : leur vendor core-person devra être re-synchronisé (ou le package republié) pour qu'un token `person` y fonctionne — la sécurité (default-deny), elle, est déjà active partout via le core live.
- **Tracking widgets — route traçante QR `/q/{uuid}` + page_view (2026-06-28)** : tout scan de QR de widget passe désormais par `GET /q/{uuid}` (`widget.qr.redirect`, publique, throttle 120/min, regex uuid) → `WidgetController::trackScan` : logge un `qr_scan` (`User_widget::recordEvent`, cf. `core/CLAUDE.md`) puis **302 vers l'URL finale** (helper privé `resolveWidgetTargetUrl` = `getShareableUrl` : page hostée `/w/{slug}` ou `embed_url`) ; widget inconnu/`disabled`/sans URL → 302 `infoparcelle.fr` SANS event. `qrCode()` encode maintenant `route('widget.qr.redirect', uuid)` au lieu de l'URL finale → **régénérer le slug ou changer l'embed_url ne casse plus les QR imprimés** (uuid IMMUTABLE ; les vieux QR encodant `/w/{slug}` continuent de marcher). `hostedPage()` logge un `page_view` (channel=direct) après résolution du widget (capte visites directes + scans des QR historiques). Import `User_widget_event` ajouté. Capture de données SEULEMENT — la page de stats (scans/vues/conversion par widget) se construira plus tard SANS migration (table `core.user_widget_events` + `leads.user_widget_id`). E2E validé (curl interne routes publiques + vraie capture API). PAS de rebuild JS (le bundle widget envoie déjà `widget_uuid`).
- **Tiroir Notifications (2026-06-25) — pièges** : (1) **les notifications sont stockées avec `notifiable_type = 'App\Models\User'`** (le User du site) → toujours résoudre via `$this->user()`/`auth()->user()` (= `App\Models\User`), JAMAIS `Rootsud\Core\Models\User::find()` (morph class différente → relation `notifications()` renvoie 0). (2) **Exclure `MessagingPush`** (messagerie) du flux ET du badge — l'ancien badge welcome utilisait `getUnreadNotifications($pid)` sans skip → sur-comptait (incluait les messages que le dropdown core n'affiche pas ; user 2 = 59 « non lues » étaient en fait 6829 messages). (3) Le `data` JSON varie par type → `notificationMeta()` lit les clés communes + libellés par `class_basename($n->type)` (fallback générique si type inconnu). (4) `refreshNotification()` (core auth.js) early-return si `#notifications` absent → retirer le dropdown ne casse rien (mais le badge welcome n'est plus rafraîchi en temps réel par le core — il l'est au chargement + à l'ouverture du tiroir, comme avant).
- **Tiroirs dashboard (`.pf-*`) — pièges transverses** : (1) **`APP_ID` (env) = id du PROJECT_SITE, PAS le `project_id`** → `hasSetting($projectId, …)` doit recevoir `Project_site::find(env('APP_ID'))->project_id` (helper mémoïsé `portalProjectId()`), jamais `env('APP_ID')` directement (sinon lecture/écriture user_setting divergent). (2) **`far fa-*` ne rend pas** (seul FontAwesome `solid`/`fas` est chargé, le `regular` affiche un carré vide) → toujours `fas fa-*` (clock, eye→filter, etc.). (3) **PHP 8.1 : pas de `const` dans un trait** → `portalEventMeta()` est une méthode statique. (4) Temps réel SANS toucher au core `auth.js` : `window.refreshLeadDetail` (acquisition.js, site) est déjà le `call_func` des notifs `lead.portal.*` → il appelle `window.dashboardPortalFeedPush` (défini dans `notifications-drawer.js`) qui bump le badge + prepend dans le tiroir unifié. (5) Carrousel chips : `bindChipsCarousel()` appelle `updateChipsArrows()` à CHAQUE ouverture (hors guard `__nfBound`) + `setTimeout(…,330)` post-transition, sinon les flèches restent `d-none` jusqu'au 1er scroll. (6) `.pf-drawer-body { overflow-x:hidden }` (overflow-y:auto force overflow-x:auto → scrollbar horizontale parasite) + `.pf-item-label-text` ellipsis.
- **Visibilité des templates /parametres#templates par CAPACITÉ de profil (2026-06-12)** : l'isolation par `audience` ne couvrait que les variantes — un particulier voyait les 4 mandats Hoguet/compromis/bordereau/lettre de prospection, un prestataire et un notaire voyaient TOUT le catalogue pro. Fix : champ **`capability`** ajouté à chaque kind de `config/document_kinds.php` (mandats+avenants→`hoguet.mandates` ; compromis/bon_visite/cr_visite/bordereau/infos précontractuelles/lettre_prospection/offre_achat/bail_commercial/bail_professionnel→`hoguet.docs` ; baux habitation+caution+préavis+congé+quittance→`rental.docs` ; etat_lieux→`rental.edl` ; pv_remise_cles→`keys.pv` ; demande_pieces→`docs.request` ; avis_valeur→`estimation.avis` ; close_sale_seller→`close` ; portal_link_invitation→`portal` ; portal_match_added→`matching` (entrée ajoutée) ; mandat notarial→`notarial.mandate`) + helper **`document_kinds_forbidden_for($user)`** (document_kind_helpers.php) + filtre `whereNotIn` dans `SettingController::listTemplates` (appliqué AUSSI aux forks perso/agence) + gardes 422 serveur dans `storeTemplate`/`updateTemplate`/`duplicateTemplate`. Résultat par tier (E2E JSON validé) : pro = 60 system (tout), particulier = 21 (variantes pack + avis + demande pièces + EDL), notaire = 10 (mandat notarial + avis + pièces + EDL), prestataire = 8 (avis + pièces). Kind sans `capability` au registre = visible partout. Le dropdown kinds de l'éditeur était déjà gaté (Solution C) — c'est la LISTE et les endpoints qui ne l'étaient pas. Audit rejouable : `.insights/templates-visibility-2026-06-12/`.
- **Signature MULTI-PARTIES électronique : PDF final re-rendu avec les signatures (fix 2026-06-12)** — `ClientPortalPublicController::submitSignerSignature` : à `all_signed`, `MultiPartySignatureProvider::signByToken` ne re-rendait JAMAIS le PDF → `proposal.path` restait le PDF de génération ({{signature_block}} strippé) : le document « signé par toutes les parties » (compromis multi_portal, baux via SendSignerInvitations) ne portait AUCUNE signature ni preuve eIDAS (`buildMultiPartySignatureBlock` était du code mort). Ajout (miroir du flux mono `signProposal`) : re-render avec `metadata.template_vars` + `signature_block = buildMultiPartySignatureBlock` (site résolu via APP_ID), update `path`/`filename` « signé », `User_document` partagé portail + `Lead_activity` épinglée. E2E validé via le vrai endpoint `POST /sign/{token}` ×2 (proposal de test 361, nettoyée) : bloc « SIGNATURES ÉLECTRONIQUES — eIDAS niveau 1 » avec les 2 signataires horodatés dans le PDF final. **Récap matrice signature** : papier mono/multi = cases manuscrites en dur (markProposalSignedManual, pas de re-render) ✓ ; électronique mono = re-render signProposal ✓ ; électronique multi = re-render submitSignerSignature ✓ (fixé) ; bail mode-aware via `lease_signature_blocks` (papier = cases par partie / e-sign = mention eIDAS) ✓ ; preview multi en cours = placeholder « Aperçu — signature non encore apposée » ✓. **Limitation assumée** : en e-sign MONO les cases papier en dur restent visibles à côté du bloc eIDAS (seul le bail est mode-aware). **Preview** : `lease_signature_blocks` démo (variante papier 2 cases, même markup que `buildBailSignatureBlocks`) ajoutée à `buildPreviewExtraVars` — les baux s'aperçoivent désormais AVEC leurs cases. Harness pagination rejouable : `.insights/signature-pagination-2026-06-12/` (render_all.php = 46 templates → PDF + comptage pages + scan d'encre dernière page).
- **URLs cliquables dans la timeline (notes onglet comm) (2026-06-12)** — la closure `$renderMentions` de `acquisition/partials/_lead_timeline_inner.blade.php` (partagée par le rendu initial ET le rechargement AJAX `/acquisition/lead/{id}/history`, donc UNE seule modif couvre tout) linkifie les URLs http(s) en `<a target="_blank" rel="noopener noreferrer nofollow">`. **⚠ PIÈGE : une note libre courte est stockée dans le `title` de l'activité, PAS dans `description`** (qui reste vide) → le `tl-desc` (ligne 196, `{!! $renderMentions($entry['description']) !!}`) ne la couvre PAS. La linkification s'applique AUSSI au **titre** : ligne ~158, si `activity_type === 'note'` on rend `{!! $renderMentions($entry['title']) !!}` (sinon `{{ }}` brut pour status/task/field_changed/doc — titres système sans URL, restent échappés). Symptôme du bug initial (lead 662, note `https://data.geopf.fr/wms-r/wms`) : URL en texte brut dans `tl-title > span.flex-grow-1`, clic inerte. **Ordre des passes `$renderMentions`** : `e($text)` → mentions `@Nom` → linkification `preg_replace_callback('~https?://[^\s<>"]+~', …)`. **Sécurité** : la regex exclut `< > "` → ne traverse jamais les `<span>` de mention ni aucune balise (`<script>` reste échappé) ; URL déjà HTML-safe (via `e()`) réinjectée telle quelle. `&amp;` des query strings préservé ; ponctuation finale (`.,!?)]`) exclue du lien SAUF si l'URL finit par une entité HTML. 100% Blade serveur (le JS recharge via `/history`) → `view:clear` suffit, pas de rebuild. Vérifié E2E (lead 614 description + lead 662 titre → 2 liens cliquables).
- **Badges statut lead = statut PROJETÉ pipeline viewer (2026-06-10)** — fin de l'incohérence kanban vs reste de l'app (un lead partagé affichait le statut brut du pipeline du PROPRIÉTAIRE, ex. 648 « Contacté » #6366f1 au lieu de « Non qualifié »). **Règle** : tout badge de statut hors kanban passe par `$lead->statusForViewer(auth()->id())` (core-city-user, cf. son CLAUDE.md). **Surfaces câblées** : `dashboard/_top_leads.blade.php`, `acquisition/kanban_card.blade.php` (branche `showStatus` = grille fiche parcelle), `acquisition/lead_list_item.blade.php` (pill liste), `acquisition/partials/_compatible_leads_list.blade.php` ; `TabLeadsController` eager `assignees` ; `vendor.blade.php` `LEAD_STATUS_COLORS`/`_leadPipelineStatuses` passés de `getForUser` (perso only) à **`getEffectiveFor`** (cascade perso>agence>défaut) ; `omnibar.js renderLeadCard` utilise `item.status_color` serveur (projeté) avec fallback legacy (rebuild app.js fait). Overlay carte + omnibar backend + exports + filtre statut liste/count + entonnoir : voir CLAUDE.md core-city et core-city-user. **Vérifié E2E** (fragments HTTP session réelle user 2) : dashboard, /parcel/tab/leads (p = btoa(cadastreId)), show-overlayer couche 62 (648 présent « Non qualifié », leads non partagés du même owner absents, filtre légende projeté, cluster 74=74), /city/search/json, liste+count+funnel tinker ; owner 214294 → « Contacté » inchangé (P1). **PIÈGE E2E** : dans un onglet Chrome **`visibility=hidden`** (fenêtre en arrière-plan), Google Maps ne s'initialise jamais (`map` undefined, callback idle jamais fired) → `showMenu()` ne tourne pas → panneau SPA `#colMenu` reste vide. Ce n'est PAS un bug applicatif : tester les fragments via fetch/POST directs (CSRF meta + X-Requested-With) au lieu d'attendre le rendu visuel. **Nav SPA dashboard → fiche lead (2026-06-10)** : `Dashboard.navigateToLead` (dashboard.js) délègue à **`window.spaNavigate(url)`** (0 rechargement), fallback `location.assign` (deep-link) si hook absent/échec — remplace la navigation pleine du 2026-06-04 (la nav SPA d'alors était flaky). **`spaNavigate` fiabilisé** (app.js:116) : quand le menu Acquisition est absent, il passe par la **branche NATIVE `data.lead`** du case acquisition de showLeftMenu (`showLeftMenu('#…-acquisition', { lead: id }, undefined, true)`, menu-map.js:720) = pushState `acquisition_lead` + `Acquisition.init()` PUIS `showDetail(lead, true)` séquencés dans le MÊME callback de loadPostContentMenu — zéro course (l'ancien pattern `callback open` séparé pouvait laisser la fiche au loader). Menu déjà chargé → `showDetail(leadId)` direct (pushState par showDetail). Bénéficie aussi aux toasts `toastTo`. Vérifié E2E : depuis /dashboard frais, marqueur window survit (0 reload), URL `/acquisition/648` + `history.state.page='acquisition_lead'`, fiche rendue (7 panels), 0 erreur console. **Complément (2026-06-10)** : compteur leads de la fiche parcelle (`parcel/card.blade.php:70` — badge nav `.parcel-leads-count-nav` + pill header `.parcel-lead-count` + `data-leads-count`, même variable `$parcelLeadsCount`) passé de `where('user_id')` owner-only à **`sharedFor`** (cohérent avec l'onglet/TabLeadsController) ; endpoint `AcquisitionController::parcelLeads` (refresh JS `loadParcelLeads`/`updateParcelHeaderPill`) : périmètre `sharedFor` déjà OK mais `status_label`/`status_color` passés au **statut projeté** (`statusForViewer` + eager `assignees`). Vérifié : parcelle 66227000AM0054 → compteur 0→1, JSON 648 « Non qualifié » #94a3b8.
- **Sprint Prospection → Portail self-onboarding (2026-06-10)** — la lettre de prospection (modal `modalSendLettreProspection`, 3 canaux) pousse désormais le prospect vers son **espace client**, avec collecte d'identité + consentement par le prospect lui-même. **(1) `dispatchProspectionDoc`** : garantit un token portail AVANT le switch canal (`HasClientPortal::ensureProspectionPortalToken` via classe anonyme — token réutilisé/prolongé 180 j, ou créé `requires_2fa=false` si aucun contact joignable) ; le canal **download** passe `['portal_url'=>…,'has_portal'=>'1']` en extraVars au `pdf()` (→ QR du PDF). Email/SMS résolus par les jobs (cf. core-city-user). **(2) Flux claim** : routes `GET|POST /client/{token}/claim` (groupe `['throttle:60,1','portal.token']`, AVANT show) → `ClientPortalPublicController::showClaim/submitClaim` + helper `portalNeedsClaim()` (= token sans contact OU contact sans email NI tél) ; `show()` redirige vers claim si besoin. `submitClaim` : validation stricte (consent `accepted` obligatoire, email OU tél requis), crée le Contact (owner=`$lead->user_id`, `consent_source='portal_claim'` horodaté), FK legacy `contact_id`/`contact_seller_id|buyer_id` si libres + pivot `lead_contacts` (rôle selon type, primary si premier), remplit `requirement_type`/`requirement_data` **UNIQUEMENT si vides**, token → nominatif + `requires_2fa=true` + cookie `pv_` posé (l'appareil du prospect n'est pas éjecté ; un AUTRE appareil passe par le 2FA — le code part vers l'email/tél qu'il vient de fournir), activité `metadata.event=portal_claimed` + `notifyAgent` temps réel + fires `portal_claimed`/`contact_has_email_or_phone`/`lead_requirement_filled` (avec pre-check `checkSingleCondition`). Vue `client_portal/claim.blade.php` autonome mobile-first (pattern `verify.blade.php` : accent + agence ; 4 cartes projet affichées seulement si lead non typé ; critères budget/surface/pièces → clés `requirement_data` préfixées). **(3) Overlay de bienvenue** : `TrackPortalVisit` expose `is_first_visit` (calculé AVANT increment) ; `show()` passe `$showWelcome` (1ʳᵉ visite humaine OU flash `cp_just_claimed`, jamais preset guarantor) ; `layout.blade.php` rend l'overlay `#cpWelcomeOverlay` (CSS/JS inline, animation, 3 points, dismiss bouton/backdrop/ESC, `prefers-reduced-motion` géré) — jamais ré-affiché (flag serveur one-shot). **(4) Checkbox consentement** dans `contact_form_content.blade.php` (cochée = `consent_contact=1` envoyé ; décochée = RIEN envoyé → état BDD inchangé, jamais de refus implicite) + payload `modalAddLeadContact` (lead-actions.js, rebuild app.js). **(5) Promesse d'estimation tenable (2026-06-10)** : les templates ne promettent plus une estimation « prête/en 30 s » qui n'existe pas — #195 header gated `{{#if has_estimation}}` (top-level uniquement, le parser n'est pas récursif → dans les blocs `{{#if has_portal}}` le wording est une OFFRE statique toujours vraie : « estimation offerte », « obtenez votre estimation ») ; au claim d'un vendeur/bailleur sans `hasMatchingEstimation()`, `submitClaim` crée une **Lead_task priorité 2 due J+1** « ⚡ Générer l'estimation — le prospect l'attend sur son portail » (anti-doublon par titre) ; côté portail, `seller/lessor.blade.php` affichent `_estimation_pending_section` (« Votre estimation est en préparation », perm `estimation`, wrapper `.cp-estimation-section`) quand aucune estimation n'existe. **(5ter) Lettre de prospection SANS type (2026-06-10)** : envoyable dès la création du lead — `sendLettreProspection` accepte `requirement_type` vide (`[0, SELLER, LESSOR]` ; `(int)null === 0`), le bouton « Lettre prospection » apparaît aussi dans la section « Type de projet » de `_lead_quick_actions` (gate Hoguet conservé), le token prospection d'un lead non typé est créé en **preset `seller`** (destinataire = propriétaire ; le fallback `resolvePresetFromLead` serait 'buyer'), et `submitClaim` **resynchronise `preset_used` + `permissions`** du token sur le projet réellement choisi par le prospect (vérifié : claim « J'achète » → token seller→buyer, perms matches=true). Acheteur/locataire restent exclus de l'envoi. **(6) Contacts partiels (2026-06-10)** : `portalNeedsClaim` étendu (claim aussi si firstname OU lastname manquants) ; `showClaim` pré-remplit depuis le contact existant (`prefill` + titre « Plus qu'une étape ! » via `is_completion`) et `ask_criteria` affiche le bloc critères même pour un lead TYPÉ sans `requirement_data` (sans les 4 cartes, type connu côté serveur → JS `SERVER_TYPE`) ; `submitClaim` **MET À JOUR** le contact existant du token (jamais de doublon — email/tél complétés seulement si fournis, identité écrasée par la saisie du prospect, consentement re-horodaté) sinon crée ; les critères remplissent `requirement_data` dès qu'il est vide, même si le type venait de l'agent. Vérifié curl : contact 1 seul (update), `sellerSurfHab/sellerRooms` posés, tâche créée, placeholder portail affiché. **PIÈGES** : (a) QR `data-qr-module-style="dots"` = indécodable → `square` obligatoire (vérifié jsQR : le QR du PDF #195 décode l'URL portail exacte) ; (b) template PDF pleine page → marqueur `data-full-page-design` sinon le letterhead `family=letter` pousse tout sur une page 2 (page 1 quasi vide) ; (b2) **fuite CSS éditeur de zones (corrigé 2026-06-10)** : un template à `<style>` racine injecté dans `#tplZonesPage` appliquait ses règles globales (`body{font-family}`, `*{}`) à TOUTE l'app (un `<style>` reste actif même en display:none) → `tplZonesInit` extrait désormais les `<style>` dans `zonesStyles[]` (jamais injectés) et `tplZonesSerialize` les re-préfixe au save (round-trip lossless vérifié) ; l'aller-retour Quill avancé les restaure aussi (Quill les strippe) ; conséquence assumée : les zones s'affichent en style neutre, le rendu fidèle = l'aperçu PDF iframe ; (c) claim sur token nominatif → redirect (verify si 2FA) ; (d) E2E validé : modal → PDF #195 par défaut (`is_default`) + token auto + QR décodé → claim mobile (Sophie Durand) → portail + overlay + 0 ré-affichage au reload + fiche agent à jour (contact + activité + notification), 0 erreur console.
- **Audit pipeline wizard — events morts recâblés + règle backups won (2026-06-10)** — audit complet des scénarios `modalPipelineWizard` (configs `pipeline_scenarios.php`/`pipeline_presets.php`/`pipeline_crm_integrations.php` + moteur `Lead_status_trigger`). **Constat racine** : 6 events d'auto-complétion avaient checker+label mais AUCUN `fire()` sur les flux réels — les générateurs créent leurs `Lead_proposal` en DIRECT (pas via `HasProposal::addProposal` qui porte le `kindEventMap`), et la modale CR legacy qui alimentait `Lead_contact_visit` a été retirée le 2026-05-27. Or `fire()` ne coche les tâches QUE sur la clé d'event exacte (les checkers ne servent qu'aux transitions) → tâches jamais auto-cochées, transitions `visit_completed` mortes (location/mixte/prestataire). **Fixes site** : `AgendaController::updateReport` fire `visit_completed` (gate type visite, AVANT `visit_report_filled`) ; `saveEtatLieux` fire `etat_lieux_created` ; `generateCompromis` fire `compromis_generated` ; `generateCounterOffer` fire `offer_made` ; `generateSignableDoc` fire `bail_generated`/`compromis_generated`/`offer_made` selon kind ; `FeeController::saveLeadManualFee` fire `estimation_created` sur l'auto-création ; **`closeSale`** appelle `LeadStatusEntryHandler::handle` si le statut a changé (sinon les **webhooks CRM du statut won ne partaient JAMAIS à la clôture** + tâches won non créées). **⚠ FACTURATION = HORS PRODUIT (décision utilisateur 2026-06-10)** : la création de `Lead_invoice` brièvement recâblée a été RE-RETIRÉE (pas de module facturation ni d'e-facturation client) ; l'event `invoice_issued` est SUPPRIMÉ du moteur (EVENT_CHECKS/LABELS/GROUPS + checker + boot), les 3 tâches « Émettre la note d'honoraires » retirées des presets (statuts won SANS tâche), template BDD 992 + invoice 15 nettoyés ; `Lead_invoice`/`downloadInvoice`/`generateInvoicePdf` restent en LECTURE SEULE pour les 12 notes historiques — ne JAMAIS réintroduire de génération sans module facturation officiel. **Configs** : règle « un backup `all_tasks_completed` ne franchit JAMAIS un gate contractuel ni ne va vers un won immobilier » — retirés : vente avis_valeur→mandat + mandat→compromis (publier l'annonce passait le vendeur en « Compromis » !), chasseur offre→compromis + compromis→acquis (générer le compromis marquait « Acquis » !), particulier maximal visite→vendu/loue ; exceptions assumées : qualification_funnel + prestataire (won non contractuel). Ajoutés : 4 backups mixte (étapes actives), trigger BUYER mixte `contact_selected`→`lead_requirement_filled` (impasse réelle). Renommages : statut location « Bail signé »→« Préparation bail » (on y entre AVANT signature), webhook location_alur `bail`→`loue`, tâche mixte « Préparer l'estimation » `avis_valeur_sent`→`estimation_created`, « Préparer le bail » →`bail_generated`. **Validation** : harness rejouable `.insights/audit-pipeline-wizard-2026-06-10/validate_configs.php` (0 erreur/0 warning : slugs, events, won/lost, impasses, gates) + `PipelineWizardScenariosTest` enrichi (21 tests ~5200 assertions, dont matrice complète + « chaque générateur fire son event ») + **E2E complet** (user 12, manager agence 216) : wizard location_alur → 6 statuts/10 tâches TOUTES auto-complétables/8 triggers/webhook sur « Loué » payload 7 sections, puis chaîne réelle CR visite (PUT /agenda/events/{id}/report) → 2 tâches cochées + transition → … → bail signé réel → « Loué » + webhook fired (405 example.com = POST sorti) (la partie facturation du test a été annulée avec le retrait). **PIÈGES** : (1) le wizard ouvert après un `loadPostContentMenu('/parametres')` manuel exige `window.initSettingPipeline()` (le callback d'init est porté par menu-map.js) ; (2) `leads.published_at` NOT NULL (création tinker) ; (3) la contrainte unique `lead_invoices.invoice_number` était GLOBALE alors que la numérotation B12 est PAR user → migration core-city-user `2026_06_10_000001` (unique composite user_id+invoice_number).
- **Aperçu `/parametres#templates` : champs documents remplis par démo (2026-06-10)** — l'aperçu (`modalEditTemplate`, endpoints `SettingController::previewTemplate` email/SMS + `previewTemplatePdf` PDF → `HasModel::previewModelPdf`) résolvait les variables avec un lead de démo mais **sans extraVars** → tous les champs spécifiques au document (parties `contact_lessor/tenant/seller/buyer_*`, `lease_*`, `mandate_*`, `compromis_*`, `offer_*`, `caution_*`, IRL, honoraires…) sortaient VIDES (un bail affichait 38 champs vides). Ces variables viennent normalement des extraVars que les contrôleurs de génération construisent à la génération. **Fix** : helper `SettingController::buildPreviewExtraVars($kind, $lead)` = valeurs de DÉMO représentatives par type (parties M./Mme Martin / M. Dupont, loyer 850 €, dépôt, Carrez 72,50, prix 240 000, IRL 2e/2025, durée+type_label **lus depuis `config/lease_types.php`** par kind, dépôt retiré pour le bail mobilité art. 25-17…) + `fillBlanksOnly($demo, $baseVars)` qui ne comble QUE les champs réellement vides (les vraies données du lead priment). `resolveTemplatePreviewLead($user, $leadId, $reqType)` choisit désormais un lead du bon profil. **Exclus volontairement** : variables conditionnelles (`climate_*`, `agent_capital_social`, `agent_company_legal_form`, `*_chatel_block`, `*_retractation_block`) — les remplir activerait un bloc `{{#if}}` à tort. **EXCEPTION incluse (2026-06-10)** : `has_portal='1'` + `portal_url` (URL démo `/client/apercu-demonstration`) SONT fournis — à l'envoi réel le token portail est GARANTI par `ensureProspectionPortalToken` → l'aperçu AVEC le QR code (PDF lettre prospection #195/#48) et le bouton « espace client » (emails) est l'état représentatif (sans ça, l'utilisateur ne voyait pas de QR dans la preview). **Résidus normaux** : les `……` des formulaires détachables de rétractation/dénonciation (`data-retract-form`) restent vides (remplis à la main, blancs même en prod). Vérifié : ~400 champs vides → 0 (hors lignes manuelles légitimes), aperçu PDF bail mobilité complet, 0 erreur console. Diag rejouable : `.insights/recert-templates-2026-06-10/diag_preview_after.php`. **PIÈGE** : les extraVars priment sur la base (`replaceModelVariables` l.1419 `array_merge` après) — d'où le filtre `fillBlanksOnly` pour ne pas écraser les vraies données. **COMPLÉMENT (2026-06-12)** : démos ajoutées pour les kinds postérieurs au fix initial — `quittance_*` (12 clés, `quittance_is_full='1'` = quittance pleine représentative, bloc partiel masqué = légitime), `notarial_ref`/`contact_address` (PAS contact_adresse)/`mission`/`duration_months`/`remuneration_*` (mandat notarial #201), `lease_mobility_motif`, `date_today`/`parcel_address` (fournis par `generateSimpleProposal` à la génération), `dest_address_line1/zip/city` (dernier recours), `notice_motif` adapté PAR KIND (locataire = motif de réduction art. 15-I, bailleur = motif sérieux). **Scan global rejouable** : `.insights/preavis-quittance-2026-06-12/scan_previews.php` (104 templates système, exclut les `is_user_facing=false` = auto-emails à vars runtime non prévisualisables) → **0 trou user-facing** ; seuls blancs restants = «……» activité exclusive des baux commercial #149/professionnel #150 (volontaires, aucun champ de saisie) + formulaires `data-retract-form`. `generateSimpleProposal` : fallback `parcel_address` passé de `??` à `?:` (adresse chaîne vide → ligne à compléter, pas un trou). **Templates préavis/congé RESTRUCTURÉS** (#106/#107 pro + #209/#210 particulier, migration core-city-user `2026_06_12_000001_restructure_preavis_templates.php`) : murs de texte → sections bandées + liste des cas de préavis réduit + encadrés par motif + cases à cocher CSS (le glyphe ☐ U+2610 rend « ? » en DomPDF), texte légal VERBATIM (sets de variables identiques avant/après, diff vérifié) + paragraphe dupliqué de #106 corrigé.
- **Re-certification TOTALE des templates (2026-06-10)** — ~100 templates `core_city_user.modeles` re-certifiés (détail : mémoire user `legal-templates-audit.md`, livrables `.insights/recert-templates-2026-06-10/` avec **harness rejouable** harness.php + loader apply_corrected.php + snapshots avant/après). 47 templates corrigés en BDD (UPDATE direct, pas de migration). **Fixes code de ce site** : `AcquisitionController` — strtr portail 049-052 enrichi `user_fullname`/`user_function` (fuyaient littéralement), `saveCrVisite` passe les extraVars `visit.*` à `pdf()` (le PDF CR sortait VIDE), bordereau `contact_adresse` (PAS contact_address — **les extraVars PRIMENT sur les variables de base**, testé), em-dash cases signature bail → « - », labels « preneur »/« co-preneur » pour bail_commercial/professionnel ; `ClientPortalPublicController::dispatchOfferEmailToCounterpart` — clés `offer.*` SCALAIRES en metadata (le tableau imbriqué `extra` était filtré par SendModelByMail → {{offer.*}} vides) + note vendeur au call-site offerDecision. **RÈGLE metadata→variables** : SendModelByMail n'expose QUE les clés scalaires de proposal.metadata — toujours aplatir en clés pointées (`'event.start_date' => ...`), jamais de tableau imbriqué. #98 relaunch_proposal archivé (dormant par design). Copies user 142/144 resynchronisées sur 121/103 — **toute correction d'un template système doit resynchroniser ses copies user actives** (shadow).
- **Lead PARTAGÉ (lead_assignees) — accès fiche + parité owner/assigné (2026-06-05, Lot 1 & 2)** : **Lot 1 (accès fiche)** — `LoadController::acquisition` (`/acquisition/{id}` GET, deep-link `SHOW_MENU='acquisition_show'`) faisait `Lead::where('user_id', $this->user()->id)->find($id)` (owner-only) → un assigné non-propriétaire avait `$lead=null` → `$id=0` → rendait le kanban au lieu de la fiche (LE symptôme « je suis renvoyé vers le kanban »). Fix = `Lead::sharedFor(...)->where('id',$id)->first()`. Visibilité kanban/liste/compteur : scope `Lead::scopeVisibleForUser` (voir `core-city-user/CLAUDE.md`). **Lot 2 (parité « comme si c'était lui »)** — dans `AcquisitionController`, les données DU LEAD pivotent désormais sur `$lead->user_id` (le PROPRIÉTAIRE) au lieu de `$this->user()->id` (l'acteur) : (1) **matching** `leadMatches`/`buildMatchesMapContext`/`compatibleLeads` + auto-clôture `closeSale` ; (2) **épingles** `Lead_proposed_match` (lectures + **création** `proposeMatch` avec `user_id=$lead->user_id` sinon l'épingle de l'assigné serait invisible + `unproposeMatch` scopé par accès au lead + `bulkMatchAction`) et `Advert_location` ; (3) **contacts de génération de doc** via nouveau helper privé **`resolveLeadContact($lead,$contactId)`** (résout par `user_id==$lead->user_id` — identique à l'ancien pour l'owner = 0 régression — OU rattachement au lead `leadContacts`∪FK contact_id/seller/buyer pour couvrir un contact ajouté par l'assigné), qui remplace les ~18 `Contact::where('user_id',$userId)->where('id',$cid)` des générateurs (sinon contact du propriétaire → null → **génération mandat/compromis/bail/… cassée pour l'assigné**). **Gardé sur `auth()` (l'acteur)** : audit (`Lead_activity`/`Lead_task`/`Lead_historie`), perso (labels/templates), **re-partage owner-only** (4 endpoints `assignLead`/`attachAssignee`/`detachAssignee`/`setPrimaryAssignee` INCHANGÉS — décision produit), et la garde de `attachContact` (l'acteur rattache un contact de SON picker → reste scopé acteur). **NB pivot = `$lead->user_id` PAS `group_id`** (universel même si lead perso `group_id=NULL` ; neutre pour le propriétaire car owner==auth). Vérifié tinker (lead 648 bailleur partagé). Pas de migration, pas de dump-autoload (méthode ajoutée à classe existante).
- **Lead PARTAGÉ — création/affichage estimation + recherche omnibar (2026-06-05, Lot 2 ter)** : une estimation créée par l'assigné sur un lead partagé ne s'affichait pas (`leads.estimation_id` jamais écrit) car TOUS les flux estimation résolvaient le lead via `Lead::where('user_id', acteur)` → null pour l'assigné. Fix = `sharedFor` partout + l'estimation d'un lead appartient au **propriétaire** (`$lead->user_id`) : **`ActionController::saveEstimationData`** (résolution `sharedFor`, `$estimationOwnerId=$lead->user_id` pour dédup/création/cleanup PDF, snapshot lead par id), **`FeeController`** (5 résolutions →`sharedFor` : getLeadFeeDetail/saveLeadManualFee/getLeadEstimationData/recomputeLeadFees/calculatePreview + estimation auto-créée `user_id=$lead->user_id`), **`EstimationController`** (`calculate`/`toggleLeadPhoto`/`reorderLeadPhotos`), **`Parcel/TabEstimController`** (3 résolutions dont le **verrou Vente/Location** : sans résolution, `source_type` restait SALE au lieu de LOCATION pour un lead bailleur). Recherche omnibar : voir `core-city/CLAUDE.md` (leads/estimations partagés + adresse par tokens). **PIÈGE** : une estimation créée AVANT ce fix par l'assigné est orpheline (`lead_id=null`) → régénérer (la dédup cherche `user_id=owner,lead_id`). **Portail client (FAIT 2026-06-05)** : `ClientPortalController` (`index` lead sharedFor + tokens par `lead_id` ; `store` sharedFor ; `update`/`qrCode` via `resolveOwnedToken` ; helper `resolveOwnedToken` = token dont le lead est `sharedFor`) + `HasClientPortal` (core-city-user, voir son CLAUDE.md). **Surfaces additionnelles (audit complétude 2026-06-05)** : `ActionController::resolveContactLabel` + `downloadEstimationPdf` (hash+accès), `AcquisitionController::sendEstimation` (doc avis par `lead_id`), `Parcel/TabLeadsController::load` + `Parcel/TabEstimController::load` (l.89 par `lead_id`), `HasMapEmptyStates`, et **core-city** `Exports/PdfParcelsAvis` (résolution lead PDF avis) + `Exports/ParcelsAvisCompleted` (estimation par hash+accès, doc avis `user_id=$estimation->user_id`, activité sur leads partagés). **LAISSÉS owner/acteur** : 4 endpoints re-partage, audit, perso (labels/templates/médias), `HasDashboardView::$hasOpportunityLeads`, schedulers, `PdfParcelsSheet`/ref-uniqueness. Vérif : 16 fichiers `php -l` OK + 9/9 scénarios tinker (contrôles négatifs lead non-partagé inclus).
- **modalGenerateBail — découpage 2 étapes (cohérence modalGenerateMandat, 2026-06-05)** : la modale bail (étape 1 très chargée : ~36 champs) passe d'une vue unique à **2 `modal-view`** (`conditions` / `signature`) comme mandat/compromis. **Étape 1 (`modal-view-conditions`)** = conditions (durée/dates/loyer/charges/IRL/encadrement/honoraires bail) **sans `<form>`** (réutilise le flex `.modal-view` prouvé par mandat), footer « Suivant » (`.gbl-next` `data-next="signature"`). **Étape 2 (`modal-view-signature`)** = `<form id="formGenerateBail"> > {body: Signataires (4 selects) + template_picker + _signature_delivery + vérif identité, footer: Retour + Générer}` (réutilise `.modal-view > form` pour le scroll), header avec bouton Retour + « Étape 2/2 ». JS inline : handlers `.gbl-next`/`.gbl-back` (toggle `.active` sur `.modal-view-<target>`), reset sur `conditions` à l'ouverture, et **submit `submitBail` sérialise désormais TOUS les inputs de la modale** (`$('#modalGenerateBail').find('input,select,textarea').serialize()`) car l'étape 1 n'est plus dans le `<form>`. **Audit signables** : seules les modales avec `_signature_delivery` ET étape 1 chargée doivent être en 2 étapes — `mandat`/`compromis`/`avenant` déjà multi-vues, `bon_visite` (5 champs) laissé en vue unique (exception « très peu de champs »). Blade-only (markup + script inline) → `view:clear`, pas de rebuild bundle. Réf de structure : `modal_add_lead.blade.php`.
- **Modales fiche lead — style/scroll non alignés (classe `lead-modal-modern` manquante, 2026-06-05)** : les 4 modales `lead_detail` (bail, invite_guarantor, EDL entrée/sortie) utilisaient des classes `modal-content` **bespoke sans aucun SCSS** (`generate-bail-modal-modern`, `invite-guarantor-modal-modern`, `etat-lieux-modal-modern`, `etat-lieux-sortie-modal-modern`) → header/footer/scroll différents des autres modales. Le style modern est porté par **`.lead-modal-modern`** (`_lead-actions.scss`, sélecteur groupé `#modalAddLead, …, .lead-modal-modern`) qui rend `.modal-content` en flex column `height:100vh` + `.modal-body { flex:1; overflow-y:auto }` (header/footer fixes). **Fix** : remplacer la classe bespoke par `lead-modal-modern` sur les 4 (0 réf JS/SCSS aux bespoke). **PIÈGE scroll** : ces modales enveloppent body+footer dans un `<form>` (enfant direct de `.modal-view`) ; la règle flex du form était scopée `#formAddLead` → généralisée en **`.modal-view > form`** (couvre tous les `#form*` des modales modernes) sinon `.modal-body` ne scrolle pas (le form intermédiaire casse la chaîne flex). Référence de structure = `modal_add_lead.blade.php` (`modal-content.lead-modal-modern > modal-view.active > {header, form > {body, footer}}`). Rebuild CSS (SCSS→app.css) + `view:clear`.
- **Modales de la fiche lead — backdrop au-dessus / stacking context (`promoteFicheModal`, 2026-06-05)** : une modale **incluse DANS `lead_detail.blade.php`** (donc dans le fragment AJAX `#acquisitionDetailContent`, qui crée un stacking context) voit le `.modal-backdrop` (attaché au `<body>`) passer **AU-DESSUS** d'elle → modale masquée/inutilisable. Les modales incluses dans **`page_map_container.blade.php`** (niveau body, ex. `modalGenerateMandat`) n'ont pas le souci. **Comptabilité** : 21 modales du dossier acquisition = 17 dans `page_map_container` (saines) + **4 dans `lead_detail`** (`modalGenerateBail`, `modalInviteGuarantor`, `modalEtatLieuxEntree`, `modalEtatLieuxSortie`). **Fix** : helper global **`window.promoteFicheModal(sel)`** (acquisition.js) qui, à l'ouverture, **promeut la copie FRAÎCHE** (celle re-rendue dans le lead courant — important pour les modales rendues serveur comme EDL sortie qui liste les pièces d'entrée du lead) vers `<body>`, supprime les copies périmées (doublons ré-injectés par les reloads AJAX), et sort ainsi du stacking context. Appelé au **tout début** de chaque handler trigger inline des 4 modales (AVANT capture `$modal`/population, sinon `$modal` pointerait une copie supprimée) + dans le handler générique `.lhn-trigger-modal`. **Règle** : toute nouvelle modale signable/CRM doit être incluse dans `page_map_container` (body-level) OU, si elle vit dans `lead_detail` (besoin de `$lead` au rendu serveur), être ouverte via `window.promoteFicheModal('#id')` avant `showModal`. ⚠ rebuild JS (acquisition.js→app.js) + `view:clear` (scripts inline des blades).
- **Composant `_signature_delivery` — canaux contraints par mode (2026-06-05)** : dans le composant partagé `acquisition/partials/_signature_delivery.blade.php` (réutilisé par mandat/bail/compromis/préavis/bon-visite via `$ns`), la logique `SignatureDelivery._applyConstraints` (`phase2-actions.js`) désactive les canaux d'envoi incohérents avec le mode de signature : **papier → SMS désactivé** (un PDF ne s'envoie pas par SMS) ; **électronique → Téléchargement désactivé** (le client signe EN LIGNE via le lien portail ; télécharger un PDF non signé n'a pas de sens). Règle : `_setChannelEnabled('sms', sig==='electronic' && hasPhone)` + `_setChannelEnabled('download', sig !== 'electronic')` (+ email selon `hasEmail`). Réappliqué au changement de mode (`change.sigdelSign`) et à l'ouverture (`refresh`/`setAvailability`). Bénéficie à TOUTES les modales signables. Rebuild JS requis (phase2-actions.js→app.js).
- **modalGenerateMandat — honoraires de location dérivés (suppression doublon, 2026-06-05)** : la section manuelle `.gmn-location-fees` (3 inputs `fees_bailleur`/`fees_locataire`/`fees_edl`) de `_modal_generate_mandat.blade.php` faisait **doublon** avec le bloc « Prix & honoraires » (`.gmn-pricing-card`, alimenté par `FeeController::computeLeadFees` = split ALUR). Section **supprimée** (blade + JS `phase2-actions.js` toggle/clear + payload + validation/opts `AcquisitionController::generateMandat`). Les variables template `{{mandate_fees_bailleur/locataire/edl}}` (template `mandat_location` #72, **avec underscore** — `replaceModelVariables` normalise `mandate.*`→`mandate_*`) sont désormais **dérivées** via nouveau helper **`FeeController::computeLocationFeeBreakdown($lead)`** : décompose les items de `computeLeadFees` → `bailleur`=Σ items owner-only (entremise/négociation), `locataire`=Σ items recipient-only (visite/dossier/bail), `edl`=Σ items **partagés** (owner ET recipient >0 = état des lieux). Câblé dans `AcquisitionController` (`$locFeeBreakdown` avant le tableau `$extraVars`, branche `KIND_MANDAT_LOCATION`). Somme des 3 = `mandate_fees_amount` (vérifié lead 648 : 1360 + 0 + 285 = 1645). Défaut `……………` si pas d'honoraires → verrou complétude e-sign inchangé. **PIÈGES** : (1) template utilise `mandate_fees_*` underscore, pas `mandate.fees_*` ; (2) EDL = item où `part_owner.amount>0 ET part_recipient.amount>0` (heuristique robuste vs label) ; (3) le flux **bail** (`modalGenerateBail`, `lease.fees_edl`) est distinct et NON touché ; (4) rebuild JS requis (phase2-actions.js→app.js) + `view:clear`.
- **Complétude « zéro blanc » documents signables (2026-06-01)** : champs d'interface pour qu'un document parti en **signature électronique** ne contienne aucune zone `……`. **(1) Auto-fill** : prix FAI mandat (`mandate.price_fai` dans `generateSignableDoc`), honoraires compromis (`{{#if mandate_fees_amount}}`), destinataire préavis (`notice_recipient_name` dans `generatePreavis`), ville caution (`{{agent_head_office_city}}`). **(2) Verrou** : `HasModel::checkDocumentCompleteness()` → **422 `document_incomplete` + `missing[]` UNIQUEMENT si `signature_mode==='electronic'`** dans `generateSignableDoc` (mandat 71/72/73 + bail 76) et `generateCompromis` (mode `multi_portal`) ; mode papier NON bloqué ; intercepteur global `$doc.on('ajaxError.docComplete')` (phase2-actions.js) → swal checklist. Formulaires de rétractation taggés `data-retract-form="1"` (71/110/120) exclus du scan. **(3) Champs modales** : **mandat de location (72)** = section `.gmn-location-fees` (honoraires bailleur/locataire/EDL, affichée via JS `toggle(ctx.isLessor)`, sinon le verrou bloquait l'e-sign sans champ — bug corrigé en 2ᵉ passe) ; compromis = vue `modal-view-bien` (Carrez/origine propriété/prêt, étapes /6, `.gcomp-loan-zone` togglée par `#gcompCondFinancement`) ; bail = section « Loyer & révision » (IRL, `#gblZoneTendue` révèle encadrement, honoraires locataire) ; **nouvelle modale `#modalGenerateCaution`** (`_modal_groupe5_docs.blade.php`, montant garanti → lettres via `AcquisitionController::amountToFrenchWords` intl) remplace la génération directe de `.ld-open-acte-cautionnement` ; PV clés = `#prcTime`/`#prcGas`/`#prcReserves` ajoutés ; préavis bailleur = `.gp-section-reprise` (bénéficiaire reprise art. 15-II). **(4) Préservation des données dans le PDF SIGNÉ (fix critique 3ᵉ passe)** : la signature mono-partie (`ClientPortalPublicController::signProposal`) re-rend le PDF et **remplace `proposal->path`** ; elle ne passait que `['signature_block'=>...]` → les variables saisies disparaissaient du doc signé. Fix : `extra_vars` stockés dans `proposal.metadata['template_vars']` à la génération (generateSignableDoc/Compromis/BonVisite/CounterOffer) + re-fusionnés (`array_merge`) dans `signProposal` ET la branche preview de `downloadSignerProposalPdf`. Le multi-parties (`signByToken`) ne re-rend pas (garde l'original). **(5) bon_visite (66)** : champ `visit_price` + défauts « non communiqué » (jamais de blanc) ; « €» dans la valeur. **(6) offre_achat (77)** : `generateCounterOffer` construit désormais TOUS les `offer.*` (price, price_words via `amountToFrenchWords`, net vendeur/honoraires depuis le mandat du bien, financing/validité/conditions/dates) — avant, `{{offer.price}}` rendait vide. **PIÈGES** : (1) défaut `……………` (3+ ellipses) côté contrôleur = ce que le verrou détecte (PAS `''`) ; (2) jamais imbriquer `{{#if}}` → variables simples pour un blanc dans une clause déjà conditionnelle (prêt) ; (3) bail = `$form.serialize()` (champs `name=` auto) ; (4) modale bail/mandat/bon_visite dans `lead_detail.blade.php` (absentes de la liste = normal) ; (5) tout générateur de doc signable DOIT stocker `metadata['template_vars']` sinon le PDF signé perd les données. Détail : `.insights/completude-documents-signables-PLAN.md` + mémoire `legal-templates-audit.md`.
- **Widgets hosting (Sprint 2026-05-23)** : (1) Migration core exécutée DEPUIS `/administration` (`php artisan migrate` détecte les migrations core via ServiceProvider). (2) `hosted_slug` est UNIQUE partiel (`WHERE hosted_slug IS NOT NULL`) — accepte les NULL multiples ; généré uniquement au 1er passage en mode `hosted` via hook `saving` du modèle. (3) Route `/w/{slug}` doit pointer sur `WidgetController` (PAS WidgetScriptController) car elle a besoin de `$this->site` via `HasPage` du core pour résoudre `project_id` lors du `ensureWidgetApiKey`. (4) Le slug est IMMUTABLE même si l'user change de mode (préserve la stabilité des QR imprimés). (5) `hosting_mode='disabled'` force `is_active=false` côté contrôleur (cohérence). (6) Le bouton `integration-toggle` + panel `wt-integration-panel` ont été supprimés des 3 forms — leur contenu est dans `_distribution_section`. Anciens `id="embed-code"`/`id="token-select"`/`id="copy-embed"` remplacés par sélecteurs `#panel-embed code.wt-embed-code` / `#wt-embed-token-select` / `#panel-embed .copy-to-clipboard`. (7) Format slug `{brand}-{token8}` : brand prend `name → agency_name → typeSlug(type)` (fallback `estimation`/`simulation-dpe`/`opportunite`), token = `Str::lower(Str::random(8))` (5 tentatives anti-collision). Regex route `[a-z0-9][a-z0-9-]{5,79}`. (8) Page publique `hosted_page.blade.php` est auto-portante (pas de `@extends`), Shadow DOM via script externe, OG meta complet, footer "Propulsé par InfoParcelle" si `powered_by=true`.
- `filter_profile()` sur User est deprecated → utiliser `saved_parcel_searches()`
- Bug : `Filter_profile_map.profile()` reference `App\Models\Profile` au lieu de `Filter_profile`
- Autoload PSR-4 override en dev (`../core/src/`) peut confliter avec vendor
- API `routes/api.php` vide localement, les routes viennent du core-city
- `@props()` ne marche PAS avec `@include()` → utiliser `@php $var = $var ?? default @endphp`
- print.blade.php = 1660 lignes avec CSS inline
- jQuery `.off().on()` obligatoire pour le contenu charge en AJAX
- Codes HTTP custom 432-435 pour gestion d'abonnement
- Migration `2026_01_07_120000` est destructive et non reversible
- Widgets : Shadow DOM, Vanilla JS, build Rollup separe — ne PAS inclure dans webpack
- Widget DPE : pas de mode Express (4 experiences seulement)
- Widget Opportunite : score 0-100 avec 4 niveaux (excellent/favorable/neutre/defavorable)
- Widget config pages : CSS responsive complet (3 breakpoints 991/767/480px) — toolbar `wt-toolbar` identique dans les 3 pages
- Widget `is_active` : si desactive, le script embarque ne rend rien (check cote JS apres appel API config)
- Estimation Unification : `ActionController.parcel_avis_valeur()` cree/maj `Estimation` + `Estimation_criteria` + `Estimation_text` depuis les donnees `parcel_user_detail` au moment de la generation PDF. Le `hash` est injecte dans `$parcelInfoUserDetails` pour que `PdfParcelsAvis` charge le modele structure. `year_construct` est tronque a 4 chars (InfoParcelle stocke des ranges "1975-1984")
- `EstimationController` : dual-write des textes AI dans `Estimation_text` (si Estimation existe) en plus de `parcel_user_detail`
- Dashboard : utiliser `createChart()`/`destroyChart()` du core (PAS `new ApexCharts()`), appeler `destroyCharts()` en debut de `init()` pour eviter les doublons
- Dashboard sparklines : `createChart(el, options, false)` — le 3eme param `false` est OBLIGATOIRE pour passer ses propres options completes
- Design tokens : `_design-tokens.scss` + `_animations.scss` + `_empty-states.scss` doivent etre importes EN PREMIER dans boot.scss (avant map.scss) car les autres SCSS referent les keyframes et classes
- Empty states : utiliser `.crm-empty` (ou `.crm-empty.crm-empty-compact` pour les espaces reduits) au lieu du pattern `text-center text-muted py-3` + `<small>` pour tous les nouveaux empty states
- **Scroll fiche lead (refonte 2026-06-02)** : SEUL `.ld-pipeline-row` est pinné (flex-shrink:0 hors scroll) ; Hero+KPI+tab bar+panels vivent dans l'unique `.scrollbar-container` (PerfectScrollbar), la tab bar `.ld-tabs-nav.ld-tabs-bar-v2` étant `position:sticky;top:0;z-index:8`. **PIÈGES** : (1) `position:sticky` marche dans PerfectScrollbar (scroll natif, pas de transform) — mais (2) **`offsetTop` ET `getBoundingClientRect()` d'un élément sticky reflètent le déplacement sticky quand il est collé** (Chrome) → pour calculer la position de flux « naturelle » de la tab bar (= hauteur Hero+KPI, seuil de collage), NE PAS utiliser `offsetTop`/rect (s'effondre à `scrollTop`) mais **sommer la hauteur des frères PRÉCÉDENTS** (Hero + KPI), invariante au scroll (cf. `initDetailTabs().activate()`). (3) Le scroller reste `#acquisitionDetailContent .scrollbar-container` → tous les helpers (`_smoothScrollInDetail`, save/restore) inchangés. (4) Pas de double scrollbar : aucun panel/wrapper n'a d'`overflow-y` ; ne JAMAIS ré-introduire un scroller imbriqué. (5) Le wrapper `.ld-fixed-header` n'existe plus (ni DOM, ni CSS, ni JS).
- Lead detail V3 (header compact + onglets) : 4 onglets (Activite/Actions/Contexte/Dossier), onglet actif sauvegarde dans localStorage('lead_detail_active_tab'). ~~`.ld-fixed-header` = sticky top:0 z-index:10 contient header V2 + pipeline + onglets~~ (DISSOUS le 2026-06-02, cf. point « Scroll fiche lead » ci-dessus). `.ld-header-v2` = 2 lignes (retour+badge+adresse+dropdown | chips KPI+contact+prochaine action pill+labels). Onglets `.ld-tab` avec `display:block/none` inline (bug Chrome 33+ stylesheets). Badges compteurs sur onglets (Actions, Contexte, Dossier). `initDetailTabs()` appele dans `showDetail()` apres injection HTML. Navigation cross-menu Lead→Annonce via `.ld-view-advert` → `BreadcrumbNav.setSource('advertList', config)` avec sessionStorage `acquisition_return`. Apercu parcelle `.ld-parcel-preview` (SVG cliquable → `showParcelCard()`). Fichiers supprimes : `_lead_next_action` (integre header), `_lead_bottom_bar` (actions dans dropdown), `_lead_suivi` (orphelin)
- Status pipeline V2 : le partial `_lead_status_pipeline.blade.php` affiche 5 dots avec labels + track ::before + progress bar ::after (scaleX via --pipeline-progress). Check icons dans dots completed. Exclut STATUS_ABANDONED — le bouton "Abandonner" est dans le dropdown header
- Lead detail `showDetail()` : `var self = this;` est OBLIGATOIRE en haut de la methode car les callbacks $.get utilisent `self.initDetailTabs()` etc.
- Kanban card enrichie : `$lead->badgeFlags()` doit etre eager-loaded dans le kanban, `$lead->latestActivity` pour l'activity dot. `contactPhone` extrait de contact/contactSeller/contactBuyer pour les hover actions
- **Dashboard — tâches & clic lead (2026-06-04)** : (1) libellé du bloc `#dashboardKpiSummary` = **« Synthèse leads · 30 jours »** (`_kpi_summary.blade.php`) pour clarifier que les KPI portent sur les leads. (2) La card « Tâches à traiter » (`_overdue_tasks.blade.php`, var `$pendingTasks` via `HasDashboard::getPendingTasks`) + le compteur « X retard » (`getDashboardStats`) **masquent** les tâches des leads **perdus** (`Lead_task::excludingLostLeads()`) ET des leads **non visibles** par l'user (`Lead_task::forVisibleLead($userId)` = `sharedFor`) — sinon clic = 404. Scopes définis dans core-city-user. (3) **Clic** `.dashboard-task-item`/`.dashboard-lead-item` → `Dashboard.navigateToLead(leadId)` fait `window.location.assign(APP_URL+'/acquisition/'+leadId)` (**navigation pleine** = deep-link `SHOW_MENU='acquisition_show'` qui ouvre la fiche de façon fiable au bootstrap, map.js:1363). L'ancien `showLeftMenu(acq)+setTimeout(500,showDetail)` retombait sur la LISTE par course (showDetail avant l'injection de `#acquisitionDetailContent`). **⚠ PIÈGE** : `public/js/app.js` n'a pas de hash en dev + Apache `immutable` → `ignoreCache` ne recharge pas le nouveau app.js (vérifier via `Dashboard.navigateToLead.toString()`) ; hard-reload navigateur ou cache-bust `?v=` temporaire dans `vendor.blade.php` pour tester. Prod = `mix.version()` → OK.
- Dashboard hub vs KPI : `dashboard.js` = hub simplifie (animateKpis, refreshActivity, openEventPreview via Agenda.openShowModal avec lazy-init eventTypes), `dashboard-kpi.js` = bundle separe page KPI dediee (window.DashboardKpi)
- Dashboard events preview : clic sur un RDV dans _upcoming_events ouvre `#agendaShowModal` (modal agenda) directement sur le dashboard. `Dashboard.openEventPreview(eventId)` fait lazy-init `Agenda.eventTypes` via `GET /agenda/event-types`, fetch l'event via `GET /agenda/events`, puis appelle `Agenda.openShowModal()`. La modal `_event_show` est incluse dans `page_map_container.blade.php` (layout global)
- Vues POST-loaded (dashboard, etc.) : utiliser `auth()->user()` directement, pas `$user` passe par le controller
- Ajout nouveau menu sidebar : DOIT etre declare dans TROIS fichiers JS — `map.js` (`showMenu()` switch sur `SHOW_MENU`) + `menu-map.js` (`showLeftMenu()` switch sur l'id + entree dans `_menuDefaults`) + `app.js` (`switch(state.page)` du handler popstate). Oublier l'un des trois = menu cassé (l'item s'active mais le panneau gauche reste vide, ou le bouton retour ne restaure rien). Le `<li id="menuOnMapContainer-btn-left-menu-XXX">` dans `template.blade.php` est auto-cable par `$('.app-sidebar ul').on('click','li')` dans map.js. Cote backend : `LoadController@xxx` (GET `/xxx/{map?}`) qui appelle `loader($request, 'xxx', ['titleSearchId'=>...])` (le `SHOW_MENU` = ce 2e arg) + une route POST `/xxx` qui renvoie le fragment HTML injecte dans `#colMenu` (fragment auto-portant : wrapper racine type `.setting-modern-wrapper`, pas de `@extends`). Exemple complet : module Widgets (`#menuOnMapContainer-btn-left-menu-widget`, `WidgetController`, `widget/list.blade.php`)
- Early dashboard trigger : `vendor.blade.php` contient un inline script apres app.js qui ouvre le dashboard immediatement sans attendre l'init map. Il appelle directement `toggleMapVisibility(false)` + `loadPostContentMenu()` au lieu de `showLeftMenu()` (qui depend de fonctions map non chargees). `SHOW_MENU` doit etre defini AVANT app.js
- Acquisition : utilise `toggleMapVisibility()` pour basculer carte/full-menu (plus de `halfScreenMenu`/`fullScreenMenu`). Kanban = vue par defaut (localStorage `acquisition_view_mode`). Toggle kanban ↔ list via `.acq-toggle-btn`. Drag-drop HTML5 natif entre colonnes. HasLeadHub trait complementaire de HasLead
- Agenda route ordering : les routes specifiques (`/agenda/events`, `/agenda/event-types`) DOIVENT etre avant la catch-all `/agenda/{map?}` dans web.php — sinon `events` est capture comme valeur de `{map?}`
- **RDV privé — pièges** : (1) **occultation = backend obligatoire** : le détail d'un event d'équipier transite dans le JSON `/agenda/events` et la modale détail est 100% client → cacher en JS seul = fuite DevTools. Tout nouveau champ sensible exposé dans `$formatEvent` DOIT aussi être retiré de la branche masquée `is_private_masked`. (2) L'occultation intervient **après** `injectLeadAdvertPrices` (le `$formatEvent` est appelé en dernier). (3) `is_private` non envoyé par le drag-drop (`update()` via `$request->only`) → la valeur est **préservée** (non écrasée). (4) `agenda.js` est dans le bundle `public/js/app.js` **sans hash de version** → après `npm run dev`, **hard-reload navigateur** (`ignoreCache`) sinon l'ancien JS est servi (cache). (5) NE PAS utiliser `$(el).toggle(bool)` dans agenda.js — le codebase n'utilise que `.show()/.hide()` ; `.toggle(bool)` s'est avéré inopérant ici → préférer `if(checked){.show()}else{.hide()}`. (6) Test d'occultation : l'appartenance équipe passe par `User::getTeamUserIds()` (group_access + group_users vérifiés + groupe non privé), pas juste une ligne `group_accesses`.
- `Lead::$statusLabels` est une propriete statique (pas une constante) — utiliser `Lead::$statusLabels` et non `Lead::STATUS_MAP`
- Acquisition route ordering : les routes GET specifiques (`/acquisition/labels`, `/acquisition/lead/{id}`, etc.) DOIVENT etre AVANT la catch-all `/acquisition/{map?}` — meme pattern que les routes agenda
- Documents routes : le groupe `/documents` utilise un prefix route group avec 8 routes. Les routes `defaults`, `defaults/upload`, `defaults/reorder` gerent les annexes par defaut (parametres), les routes `parcel/{cadastreId}` et `lead/{leadId}` retournent les documents par contexte
- Mobile overflow : `.app-main__outer` a besoin de `min-width: 0` (dans common.scss) pour corriger le flexbox `min-width:auto` qui empeche le shrink. Les `.row` Bootstrap dans `.dashboard-wrapper` ont des gutters reduits en mobile (dashboard.scss) pour matcher le padding reduit du wrapper
- Parametres tabs : `initSettingTabs()` appele dans le callback `loadPostContentMenu()` de menu-map.js, onglet Profil utilise `auth()->user()`, `AppServiceProvider` filtre 'profil' du account_nav
- Parametres tabs display : `initSettingTabs()` utilise `style.display` inline explicite (pas juste la classe CSS `.active`) car avec 33 stylesheets (Google Maps inlines), le toggle par classe seule ne trigger pas toujours le style recalculation dans Chrome
- Checkboxes `[data-toggle-setting]` : UN SEUL handler `change` → `window.updateSetting(key, value)`, le `change.toggleSetting` de `setting.js` (global, dans `app.js`). NE PAS re-binder un 2e handler ailleurs (un duplicate `change.autoNotif` dans `automatisation.blade.php` faisait 2 appels `updateSetting` → le 2e abortait le 1er → toast "Erreur" parasite car l'abort jqXHR déclenche `.fail()`). `updateSetting()` dans `setting.js` ignore désormais les `.fail()` dus à un abort (`textStatus==='abort'` / `readyState===0`)
- Parametres Style mobile pills : les pills de navigation entre pages intro/conseiller utilisent `style.display` inline pour toggle (meme probleme 33+ stylesheets). Pattern identique a `initSettingTabs()` et onglet Automatisation
- Content bar recherche : IDs SANS prefix (`#map-search-input`, `#map-search-btn-sector-history`, etc.) pour compatibilite avec les ~50+ references directes dans le code. `omnibar.js` intercepte `refreshSearchDropdown()` pour router toute recherche via le pipeline JSON unifie (geo + CRM + secteurs). Le search original HTML de core-city est entierement remplace. Le toggle carte appelle `toggleMapVisibility()`
- Scope pill synchro : jQuery `.trigger('change')` sur `#cbSearchType` ne declenche PAS les `addEventListener` natifs (probleme connu jQuery). La synchro pill est geree directement dans `setType()` de omnibar.js via `window.updateScopePill(type)`. Le `$` webpack est different de `window.$` → `$._data()` dans la console ne voit PAS les events du bundle
- Recherche secteurs : incluse dans les scopes Auto et Lieu via `POST /city/search/json` (scope `sectors`). Backend ILIKE + user_id + withCount('map'). Les secteurs apparaissent dans la colonne CRM du dropdown avec icone `fa-draw-polygon` violet
- `toggleMapVisibility(show)` : remplace `fullScreenMenu()`/`halfScreenMenu()` partout. Gere `window._mapVisible`, la classe CSS `full-screen-menu`, et la visibilite de `#colMap`. Toutes les references a fullScreenMenu/halfScreenMenu dans map.js, acquisition.js, menu-map.js et vendor.blade.php ont ete migrees
- `_menuDefaults` dans menu-map.js : objet de config per-menu definissant `defaultMap` (bool, carte visible par defaut) et `search` (bool, recherche content bar visible). Les preferences utilisateur sont sauvegardees dans localStorage sous cle `map_visible_<menuKey>`. **Expose en scope module (`window._menuDefaults`) au chargement** (2026-06-04, plus seulement dans `showLeftMenu`). **View-mode tri-state** (FULL/HALF/MAP via `setViewMode`, responsive.js) : un **nouvel user arrive en HALF sur le dashboard** (`dashboard.defaultMap=true` depuis 2026-06-04 → `_mode = saved || (defaultMap?'HALF':'FULL')`) ; pages map aussi HALF. Pref sauvée `map_visible_<menu>` prioritaire. **PIÈGE `/` racine (2026-06-04)** : `/` est rendue par le **core** (page SSR `dashboard/page.blade.php`) SANS `#colApp #mapLayerStart`, donc `map.js` ne déclenche jamais `showMenu()`→`showLeftMenu()` (gate map.js:1238) → view-mode jamais initialisé. Corrigé par un init `$(document).ready` (menu-map.js) qui, si `#mapLayerStart` absent, applique `setViewMode` + (dashboard) `Dashboard.init()` sans recharger contenu/URL. **3 leviers défaut HALF sur `/`** : (a) `dashboard.defaultMap=true` ; (b) SSR `page.blade.php` `var isFull=(p==='FULL')` (n'injecte plus `#colMap{display:none!important}` pour un nouvel user → carte visible sans flash) ; (c) `Dashboard.init()` à `/` (retire `<style id="dashboard-ssr-style">` sinon carte cachée par `!important` + sparklines opacity:0 + handlers non bindés = dashboard non-interactif). **PIÈGES** : (1) NE PAS gater l'init sur `isMobile()` (responsive.js:285 : renvoie `true` quand `#colMenu` pleine largeur ET `actualTypeOfScreen=='HALF'` = état de `/`, faux positif desktop) → `matchMedia('(min-width: 992px)')` ; (2) `#colMap{display:none!important}` SSR (ID+`!important`) imbattable par les classes de `setViewMode` → ne pas l'injecter ou le retirer.
- `window._mapVisible` et `window._currentMenu` : globaux dans responsive.js pour tracker l'etat carte et le menu courant
- Content bar dans `#colApp` : la content bar est **en dehors** de `#colMenu` (dans `#colApp` avant `.card-wrapper`) pour survivre aux remplacements AJAX du contenu du menu
- **Card Building "Caracteristiques Associees"** (tab-info) : 7 dropdowns editables `.bd-field` avec **3 modes** (`data-mode`) :
  - `precise` (year_construct, hauteur_bati, altitude_bati) : options range cliquables + footer input numerique exact (`bd-precise-input` + bouton "Definir" / Enter). Une valeur numerique pure ecrase tout range et est affichee formatee avec unite (`1923`, `8,5 m`, `120 m`).
  - `single` (usage_bati, nombre_etage) : options range cliquables, save scalar.
  - `multi` (mat_mur, mat_toit) : checkboxes multi-select (`bd-option-multi`), toggle avec `e.stopPropagation()` pour garder le dropdown ouvert, save array via `value[]`. Plusieurs values stockees comme N rows dans `parcel_user_detail_values` (meme `user_detail_id`).
  Persistes dans `parcel_user_details` sous prefix `estimation_*` via POST `/parcel/bati-detail` (handler `ParcelController@batiDetailUpsert`).
  Helper trait `HasParcelUserDetail::upsertParcelUserDetail($user, $encodedId, $name, $value)` accepte **scalar OU array** (single-field, sans wipe global). Si array vide / value=='' → reset (delete).
  Champs whitelist en backend (7 cles `estimation_*`). **Coexistence safe avec tab-estim** : nos cles ne sont PAS dans le payload `$dataEstimation` de `EstimationController.calculate()`, donc preservees par `deleteParcelUserDetail` qui efface UNIQUEMENT `array_keys($data)`.
  Sync bidirectionnelle `estimation_year_construct` (partage avec tab-estim) via `tab-info-bati.js` qui ecoute `change` sur `#annee-construction`.
  Helpers PHP dans `app/Helpers/bati_detail_helpers.php` (autoload composer files) : `bdRangeFor()`, `bdLabelFor($v, $ranges, $fieldType)` (smart : detecte numeric pure → format precis, sinon range), `bdMultiLabelFor(array $values, $ranges)` (concat `, `), `bdIsPreciseValue()`, `bdFormatPreciseValue()`, `bdHauteurRange()`, `bdAltitudeRange()`, `bdEtageRange()`.
  SCSS `_bati-detail.scss` (importe dans `parcel-tabs/boot.scss`) : variants `.bd-option-multi` (checkbox custom 18px), `.bd-precise-footer` (input + unit + apply btn).
  **Pris en compte dans les PDFs** : les 7 caracteristiques sont egalement utilisees dans le PDF Avis de Valeur (`page_presentation_bien.blade.php`) et le PDF Fiche Parcelle (`page_presentation.blade.php`, `page_synthese.blade.php`) avec priorite override user > BDNB. Les valeurs precises sont formatees avec unite (`1923`, `8,5 m`), les ranges textuels sont mappes vers labels lisibles. Patches dans `core-city/src/Exports/PdfParcelsAvis.php` (bloc $getUserOverride avant `$data = array_merge`) et les vues blade dans `core-city/resources/views/avis_de_valeur/classique_premium/` + `core-city/resources/views/fiche_parcelle/classique_premium/`. Le bug existant `estimation_annee_construction` (cle incorrecte) a ete corrige en `estimation_year_construct`.
- **PriceMap couche parcelles** (`/analyse-marche` zoom ≥17) : les parcelles sont maintenant colorables comme les autres niveaux. Backend = `core-city/src/Http/Traits/HasPriceMapParcels.php` (calcul TEMPS REEL via CTE SQL, reproduit la logique de `RefreshPriceCacheJobs`/`PriceEvolutionJobs`). Le format compact parcelle est aligne sur les 17 champs du format section/district pour reutiliser `compactToGeoJson`. JS : `_colorizeFromFeatures` copie maintenant tous les champs prix dans `_featureDataMap[code]` (pour les KPI chips de tous niveaux). Click sur card parcelle dans `pmZoneItems` (classe `.pm-zone-card--parcel`, attribut `data-level="parcel"`) ouvre `pmOverlayDetail` via `PriceMap.showZoneDetail(cadastreId)`. La comparaison de 2 parcelles fonctionne via le mode compare existant. `pmDetailChart` est cache si vide (parcelle = pas d'evolution sparkline). `loadPriceMapZoneEvolution` retourne `points: []` pour `Fr_parcel::class`
- **Sprint 1 (2026-05-08)** points cles :
  - `Modele::findForKind($kind, $userId)` ne filtre PAS par type — ne pas l'utiliser quand on a besoin EMAIL vs DOCUMENT specifiquement, faire une query directe avec `where('type', TYPE_EMAIL)`
  - `replaceModelVariables` (HasModel) supprime maintenant les `{{var}}` non resolus (au lieu d'afficher `[Variable non definie]` en rouge). Pour ajouter des variables custom, passer via le parametre `$extraVars` au lieu de remplacer apres l'appel
  - SMS `signature_pad` v5.1.3 du `package.json` est utilise par 2 contextes : modal `modalPdfExtraitCadastral` (parcel-tabs/tab-init.js) ET portail public client (`_documents.blade.php` charge via CDN puisque `client_portal.js` n'est pas bundle webpack)
  - `Lead_proposal.metadata` JSON stocke les preuves eIDAS niveau 1 : `signed_at, signed_by_name, signature_mode, signature_data_url (PNG base64), ip_hash, user_agent, token_id, contact_id, eidas_level=1`
  - Tests Feature dans Rootsud : pas de factories disponibles, BD multi-schemas incompatible avec RefreshDatabase. Privilegier des tests "smoke" (reflection + inspection source) + validation E2E Chrome DevTools comme principal moyen de verification
- **Scope bar `/parametres` (2026-05-11)** : barre de périmètre **manager-only** (`setting/_scope_bar.blade.php`, classe `.st-scope-bar`) au-dessus de la nav d'onglets — bascule perso ↔ agence(s) via `?entity=user|group:N` (`SettingController::list()` lit `?entity=` + session one-shot `parametres.entity` posée par `LoadController::setting()` sur deep-link, sinon `'user'` — pas de mémorisation). Validation : `resolveEntityKey()` (abort 403 si scope group sans `canManageAgency`). Affichée si `count($manageableAgencies) >= 1`. Bouton « Changer ▾ » = dropdown Bootstrap (le toggle marche sur `/parametres` sans `$.fn.dropdown` — il y a un handler global ailleurs). En mode agence, **chaque onglet réutilise la MÊME vue/formulaire que le perso** (pas de partial agence dédié) ; seule la cible d'écriture change : `groups.metadata.settings` (préférences/apparence), `groups.branding` (3 couleurs PDF sync), `lead_statuses/lead_status_triggers/lead_task_templates`/`user_documents`/`sectors`/`fees`/`fee_template_users` au scope `group`. Tous les contrôleurs sont **entity-aware** (`resolveSettingScope` / `resolveDocEntity` / `resolveSectorEntity` / `resolveFeeEntity` lisent `?entity=` query OR body). Honoraires **= système `Fee` détaillé identique au perso** au scope group (le barème simplifié `groups.fees_grid` est ABANDONNÉ ; migration `2026_05_11_000001_extend_fees_for_group` exécutée). Hub `/compte/equipe/{id}` inchangé + 3 deep-links `?entity=group:N#tab`. Cosmétique unifiée : classe **`.st-agency-badge`** (+ variante `--ro` ambre + cadenas si non manager) partout (Documents/Secteurs/Tâches/Conformité/Honoraires) + **`.st-agency-context-bar`** (1 ligne badge + message) pour les onglets sans liste à badger ; plus aucune carte-bandeau `st-doc-section-card mb-3` en préambule. Onglet **Honoraires** : section read-only **« Barèmes de référence de l'agence »** (badge `--ro`) dans l'onglet perso d'un membre (boucle `Fee_structure` × `_display.blade.php` enveloppé d'un `.st-fees-readonly` qui masque les boutons d'action en CSS) ; `FeeController::list` + `SettingController::list` passent `$agencyFees`/`$agencyFeesName`/`$agencyFeesGroupId`/`$agencyFeesCanManage`. Onglet **Documents** : une seule carte (upload + liste fusionnés). Code mort supprimé : `setting/_scoped_block.blade.php` + `_scoped_list.blade.php` + handlers `scoped-edit/scoped-cancel/scoped-reset` (`setting-agency.js`) + SCSS `.st-scoped-*` ; `setting/agence/_fees_form.blade.php` + `_preferences_form.blade.php` ; route `POST /parametres/apparence` + `AppearanceController::update` + `submit.ag-appearance` ; route `POST /parametres/honoraires/agence` + `submit.ag-fees`. **B1 — fallback fees agence** : `HasFee::getCalculateFees` retombe sur le barème de l'agence (`$user->agencyIdForSettings()`) avant `getDefaultFee()` quand le membre n'a pas de barème perso applicable. **B2 — annexes PDF agence** : le whitelist `document_ids` dans `ActionController::parcel_avis_valeur` accepte aussi les `User_document` `scope='group'` du `group_id` de l'agence de l'user (le modal `tab-estim-annexes.js` les liste déjà via `DocumentController::defaults` → `getEffectiveDefaultAnnexes`). **B3 — banner/cover PDF agence** : déjà couvert par `User::hasSetting` qui lit `data_get($agency->metadata, 'settings.style_*')` pour toutes les clés `style_*` (y compris `style_banner_*`, `style_cover_*`). Aucun mapping legacy `groups.branding.banner_style` (l'ancien form est supprimé). Note de déploiement : `DEPLOY_AGENCY_V5.md` à la racine du projet.
- **Refonte agence v5 (2026-05-11)** : onglet **Conformité revenu dans `/parametres`** (`setting_list.blade.php`, panel `#st-panel-conformite` ; plus dans `/compte`). Héritage **perso > agence > défaut** : `SettingController::list()` calcule `$agencyCtx` (agency/group_id/canEdit/role/preferences/branding/fees_grid) + vars `confEntity`/`confCanEdit`/etc. ; chip agence dans le hero ; contexte agence exposé via `data-agency-id`/`data-agency-can-edit` sur `#settingWrapper` (HTML — **pas de `<script>` inline ajouté**). Onglets membre d'agence : Apparence & Honoraires via `setting/_scoped_block.blade.php` (valeur effective lecture seule + bouton « Modifier » → form agence si manager / form perso sinon), Préférences via `setting/_preferences_fields.blade.php` (`POST /parametres/agence/{id}/preferences` → `AgencyController::updatePreferences` → `groups.metadata.settings`), Documents/Secteurs = liste agence read-only + liste perso, Tâches = pipeline effectif (= agence) + banner lecture seule si pas manager. Composants : `_scoped_block`, `_scoped_list`, `_preferences_fields`, `setting/agence/_apparence_form|_apparence_readonly|_fees_form` (les anciens `compte/agence/_tab_apparence|_tab_honoraires` supprimés). CRUD pipeline/templates **entity-aware** via `SettingController::resolveSettingScope()` : si `?entity=group:N|user` explicite → respecté (abort 403 si scope group sans `canManageAgency`) ; **sinon, si l'user gère son agence → scope agence par défaut** (donc l'onglet Tâches du manager crée des statuts/triggers/templates d'agence sans JS qui passe `?entity=`) ; sinon scope user. + `authorizeSettingRow()` pour les MAJ/suppressions (autorise via la ligne). `webpack.mix.js` : `setting-agency.js` AJOUTÉ au bundle `app.js` (en plus du `mix.copy` standalone pour `/compte/*`) ; handlers `[data-action=scoped-edit]`/`scoped-cancel`/`scoped-reset` + submit `#formAgencyPreferences` dans `setting-agency.js` (utilise les globals core `toast`/`showBlockUI`/`simpleSwalWithoutAjax`/`loadPostContentMenu`/`APP_URL`). Les forms agence rendent `entity`/URL côté serveur — pas de JS inline ajouté dans `automatisation.blade.php` (les handlers pipeline y étaient déjà inline, non touchés). SCSS `.st-scoped-*` dans `_setting-agency.scss`. `/user/sectors-list` utilise `$user->effectiveSectors()`. `AppearanceController::resolveForPdf` étendu (perso branding > `style_*` legacy > agence > défaut). **S5b/c (fait)** : pipeline appliqué à l'agence — `Lead_status::remapLeadsForUser($uid, $groupId)` appelé à `acceptJoin` (→ agence) et `leave`/`removeMember` (→ `currentAgency()` restant ou perso) + migration backfill `2026_05_13_000006` (env `AGENCY_PIPELINE_BACKFILL_DISABLED=true` pour skip) ; `getForUser`→`getEffectiveFor` aux call-sites `kanbanData`/`HasDashboard`/`HasContact`/`AcquisitionController` ; `getInitialStatus`/`findByBaseStatus` effective-aware ; `kanbanData` 1re colonne capte aussi les leads orphelins (`lead_status_id NOT IN` pipeline) ; `Lead_status_trigger::fire()` filtre par `from_status_id` seul (scope-agnostic) ; `Lead_task_template::scopeForLeadStatus` sans filtre `user_id`. Branding PDF couleurs : `User::hasSetting` mappe `style_color_*` → `groups.branding.*_color`. Secteurs : `core-city SearchController` (sectorHistory + CRM scope) → `effectiveSectors()` (le OR est dans une closure extérieure pour les `->where()` chaînés). **Toujours différé** : `HasFee::getCalculateFees` fallback `groups.fees_grid`, câblage `getEffectiveDefaultAnnexes` au sélecteur d'annexes PDF, bannière/couverture PDF agence, remap leads au archive/reactivate d'agence (filet : kanbanData met les orphelins en 1re colonne). Plan : `.insights/agence-parametres-refonte-v5-PLAN.md`. Migrations exécutées **depuis `/administration`**.
- **Refonte agence v5 — pièges** : (1) `lead_statuses` a `unique(user_id, slug)` → `Lead_status::seedAgencyDefaults` et `storeStatus` en scope agence DOIVENT générer un slug via `generateSlug($ownerId, $name)` (suffixé `-1`,`-2`…) car le pipeline agence est stocké avec `user_id = ownerUserId` (FK NOT NULL) + `group_id` + `scope='group'` ; (2) `Lead_status::getEffectiveFor($user)` AUTO-SEED le pipeline agence depuis les défauts système si vide — donc le 1er affichage de `/parametres` par un membre d'agence crée 6 statuts + 2 triggers `scope='group'` ; (3) `core/src/Http/Traits/HasAgency::currentAgency()` est désormais mémoïsé par instance (`$_cachedCurrentAgency`) car `User::hasSetting()` l'appelle à chaque miss.
- **Onglet Apparence aplati (2026-05-11)** : le panel `#st-panel-apparence` n'utilise plus `_scoped_block` ni `<details>` ni bandeau ni toggle de scope — il affiche **directement `setting/tabs/style.blade.php`** (le styleWrapper riche) pour tout le monde. Modèle : un **membre d'agence hérite par défaut de l'apparence de l'agence** (via `$user->hasSetting()` : perso > agence > défaut, déjà appliqué aux vars `style*` dans `list()` — en pratique seules les 3 couleurs sont héritées via `groups.branding.*_color`, sauf si l'agence a un `groups.metadata.settings.style_*` complet ; `groups.branding.banner_style`/`cover_type` du vieux form sont dans un vocabulaire incompatible avec `style_banner`/`style_cover_type` → non mappés) ; dès qu'il modifie une valeur, c'est sa **perso** (`user_settings`) qui est créée/utilisée et prime. **Bouton `.st-ap-revert-wrap` > `[data-action=apparence-reset-perso]` (« Revenir à l'apparence de l'agence ») rendu si `$isAgencyMember`, visible quand une perso d'apparence existe** : `$apHasPersoOverride` (calculé dans `list()` : existe un `user_setting` `style_*` OU `preview_theme`/`plan_theme`) → rendu visible/`display:none` ; **révélé après une modif** par `revealRevertBtn()` dans l'IIFE du styleWrapper ; **masqué après le reset** (le reload recalcule `$apHasPersoOverride=false`). Le directeur est traité comme tout membre d'agence (édite en perso, voit le bouton). `#styleWrapper data-entity="user"` (hardcodé). Le styleWrapper enregistre via **`POST /parametres/style/setting`** (`SettingController::styleSetting` — perso : `updateUserSetting` ; whitelist `style_*` + `preview_theme`/`plan_theme` ; reste entity-aware pour un futur usage `group:N` mais NON exposé dans /parametres) au lieu de `POST /parametre` (core) ; `POST /parametres/colors` (`saveColors`) idem perso. Le styleWrapper **redéfinit localement `function updateSetting(...)` dans son IIFE** (route vers `/parametres/style/setting` + appelle `revealRevertBtn()` au succès) → capture tous les call-sites internes sans toucher au `window.updateSetting` global (utilisé par les autres onglets). `DELETE /parametres/apparence/perso` → `AppearanceController::resetPersonal` (delete `User_setting` `style_*` + `preview_theme`/`plan_theme` ; `profile.branding=null` — note : `core.user_profiles` n'a PAS de colonne `branding`, ce 2e effet est un no-op ; le mécanisme perso réel = ces user_settings). Handler JS `click.ap-reset-perso` dans `setting-agency.js` (confirm `simpleSwalWithoutAjax` → POST `_method=DELETE` → `reloadSettingsTab`). `SettingController` `use HasSetting` (pour `updateUserSetting`). Const `SettingController::STYLE_BRANDING_MAP`. Partials supprimés : `setting/agence/_apparence_form.blade.php`, `setting/agence/_apparence_readonly.blade.php` (`_scoped_block` reste utilisé par l'onglet Honoraires). Le cas « user owner+membre d'une agence + membre de plusieurs équipes simples (`Group` type 0) » est OK : `HasAgency::currentAgency()` filtre `type=GROUP_AGENCY(10)` → `$isAgencyMember` ne réagit qu'aux vraies agences, pas aux équipes simples. **Différé** : édition de l'apparence DE L'AGENCE non exposée dans /parametres (le endpoint `styleSetting`/`saveColors` est entity-aware `group:N` mais aucun appelant ne l'utilise — à câbler dans le hub `/compte/equipe/{id}` plus tard) ; logo perso-only (upload `estimate/logo` reste perso — TODO près de `#styleLogoZone`).
- **Onglet Préférences aplati (2026-05-11)** — même pattern que l'onglet Apparence : le panel `#st-panel-preferences` n'affiche plus le bloc `formAgencyPreferences` (`st-doc-section-card` "Préférences de l'agence") ni la note "Mes préférences personnelles" — uniquement `@include('setting.tabs.preferences')` (les `st-pref-section`, qui lisent déjà tout via `$user->hasSetting()` : perso > agence > défaut, donc un membre d'agence voit les préférences de l'agence par défaut) + (si `$isAgencyMember`) un bouton `.st-pref-revert-wrap` > `[data-action=preferences-reset-perso]` (« Revenir aux paramètres de l'agence »), rendu visible si `$prefHasPersoOverride` (calculé dans `list()` : existe un `user_setting` parmi `AgencyController::PREFERENCE_KEYS`), **révélé après une modif** par le handler `change.pref-revert-reveal` dans `setting-agency.js` (délégué sur `#st-panel-preferences .inputElement, #st-panel-preferences [data-toggle-setting]`, ignore `e.isTrigger`), **masqué après le reset**. Les contrôles de `preferences.blade.php` (`inputElement` → POST `/parametre`, `data-toggle-setting` → `window.updateSetting`) enregistrent toujours en perso (`user_settings`) — inchangé. `DELETE /parametres/preferences/perso` → `AgencyController::resetPersonalPreferences` (delete `User_setting` `whereIn(AgencyController::PREFERENCE_KEYS)`). Le `$allowed` (24 clés) de `AgencyController::updatePreferences` est extrait en const **`AgencyController::PREFERENCE_KEYS`** (réutilisée par `resetPersonalPreferences` + `SettingController::list()`). Handler JS `click.preferences-reset-perso` factorisé avec `click.apparence-reset-perso` dans `setting-agency.js` (helper `bindAgencyResetButton`). Partial supprimé : `setting/_preferences_fields.blade.php` ; handler mort `submit.ag-prefs` (`#formAgencyPreferences`) retiré de `setting-agency.js`. Le directeur est traité comme tout membre. Reveal du bouton revert : handler `change.pref-revert-reveal` (jQuery, `#st-panel-preferences .inputElement / [data-toggle-setting]`) + listener vanilla en phase capture sur `#st-panel-preferences .preference-option / .investment-profile-option` (capture car `setting.js` fait `stopPropagation()` sur ces labels) dans `setting-agency.js`. Les options "liste de résultats" du vieux form agence (`result_list_style`/`result_list_sort`/`result_list_sort_direction`) ne sont **PAS** rajoutées dans `setting.tabs.preferences` — ce sont de vieux réglages qui se configurent dans les barres d'outils des menus concernés (search → `filter/filter_result_option.blade.php`, opportunité → `opportunity/opportunity_toolbar.blade.php`). Ils restent dans `AgencyController::PREFERENCE_KEYS` (héritage agence + reset perso). `overdue_tasks_email_enabled` + le bloc `digest_*` (aussi dans `PREFERENCE_KEYS`) restent configurables dans l'onglet Tâches (`automatisation.blade.php`). **Différé** (comme l'apparence) : édition des préférences DE L'AGENCE non exposée dans /parametres (route `POST /parametres/agence/{id}/preferences` + `AgencyController::updatePreferences` conservés mais sans UI — à câbler dans le hub plus tard).

- **Sprint C (2026-05-19) — Finition Pro CRM** : 11 nouvelles routes, ~9 méthodes `SettingController` (5 templates WYSIWYG Quill + 4 quick-replies), 2 méthodes `ClientPortalPublicController` (`downloadSignerProposalPdf`, `selfBooking`), validation `garantie_financiere_expires_at` (`ConformiteController`), bandeau Hoguet/blocage mandats dans `_lead_quick_actions`, 3 nouvelles vues (`setting/tabs/templates`, `client_portal/partials/_milestones_section`, `_self_booking_section`), quick actions de la fiche lead 100% Blade conditionnel — **la table `lead_status_actions` introduite Sprint B V2 a été abandonnée 2026-05-19** (migration `core-city-user/database/migrations/2026_05_19_000003_drop_lead_status_actions_table.php`), 3 sections hardcodées (« Documents Hoguet » + « Actions transverses », « Progression » supprimée), 4 call-sites `findForKind` enrichis avec `requirement_type` (`AcquisitionController`), audit log `field_changed` dans timeline, quick replies `:keyword` dans `_lead_quick_input`, section estimation portail enrichie + milestones + self-booking. Doc complet : `.insights/sprintC_*_LIVRE.md`.
  - **Pièges Sprint C** : (1) `CATEGORY_AVIS_VALEUR` n'existe pas → utiliser `User_document::CATEGORY_VALUATION` (`'valuation'`) ; (2) Blade-Handlebars conflict dans JS : utiliser `var L = '{' + '{'; var R = '}' + '}'` pour assembler `{{var}}` sans casser Blade ; (3) bandeau Hoguet `$mandateBlockReason` DOIT être HORS de `@if($hasDocs)` pour rester visible quand pas de docs ; (4) `Lead::$auditFields` doit lister des colonnes natives existantes uniquement (next_action_date/next_action_type/published_at/estimation_id, pas price/description) ; (5) `Quill` est exposé en global via les helpers core `createQuillEditor`/`onQuillEditor`/`updateQuillCharacterCount` (pas d'install npm requis) ; (6) Quick actions de la fiche lead sont 100% Blade conditionnel — la table `lead_status_actions` introduite Sprint B V2 a été droppée 2026-05-19 (migration `core-city-user/database/migrations/2026_05_19_000003_drop_lead_status_actions_table.php`) ; pour ajouter une action custom, l'ajouter en dur dans `_lead_quick_actions.blade.php` (instruction lignes 313-314) ; (7) Route `/sign/{token}/download` ne dépend pas du middleware `auth` (groupe `throttle:60,1` public).

- **Solution B — bandeau "Mentions Hoguet incomplètes" (2026-06-01)** : dans `acquisition/partials/_lead_quick_actions.blade.php`, nouveau bandeau `alert-warning` (après le bandeau climat) qui lit l'accessor core-city-user `Lead::mandate_compliance_gaps` (`$mandateComplianceGaps`). Affiché **uniquement si `!$mandateBlockReason && !empty($gaps) && !$compact && $isHoguetCompliant`** (le blocage dur Hoguet prime) → liste les mentions « soft » manquantes (autorité/date Carte T, RCP) qui sortiront en placeholder `« […à compléter] »` sur les PDF, avec lien vers Paramètres › Conformité. Le hard-block SIRET/médiateur (qui rend `$mandateBlockReason` non-null) est porté par `MandateRegistry` côté core-city-user. Pas de JS/SCSS (Blade seul, pas de build).

- **Mode `mandataire_reseau` : champs Hoguet du réseau saisissables (fix « à compléter » 2026-06-29)** — en mode réseau, `setting/tabs/conformite.blade.php` masquait volontairement `carte_t_authority`, `carte_t_issued_at` et la **section garantie financière entière** (`has_garantie_financiere`+`garantie_financiere_provider/number/amount/expires_at`) → ces colonnes restaient vides et `HasModel::buildHoguetVariables` (core-city-user) injectait `« […à compléter] »` dessus sur TOUS les documents Hoguet (information précontractuelle template #120, mandats, etc.) **sans aucune issue** (champ non saisissable = placeholder à vie). Fix = rendre la donnée du réseau **saisissable** en réutilisant les colonnes existantes (pattern déjà en place pour `professional_card`/`carte_t_expires_at`) : (1) `'garantie'` ajouté à `$cfVisibleByStatus['mandataire_reseau']` (l.23) **+ idem dans `setting-agency.js::visibleByStatus`** (rebuild app.js — le commentaire l.17 du blade exige l'alignement) ; (2) Carte T : autorité + date de délivrance désormais **toujours rendues** (les `@if(!$cfIsReseau)`/`@unless` retirés), libellés reframés « (Carte T du réseau) » quand `$cfIsReseau` ; (3) garantie : libellés/intro reframés « (du réseau) ». **Aucune migration** (colonnes existantes), **aucune modif `ConformiteController`** (déjà `validate`+`fill` ces champs l.124-132/163), **buildHoguetVariables inchangé** (le placeholder reste correct, ne s'affiche plus une fois rempli). E2E serveur (`checkDocumentCompleteness(120)` sur notice réseau 62) : 3 placeholders garantie AVANT → **0 APRÈS remplissage** ; non-régression agence (0)/independant (placeholders uniquement sur champs visibles genuinement vides). **Décision produit écartée** : pas de colonnes `network_garantie_*` dédiées (statut réseau exclusif → réutilisation suffit). **Audit « tous les modes » (2026-06-29)** : matrice mode × 13 clés `$req()` de `buildHoguetVariables` × sections `$cfVisibleByStatus`. Modes complets : independant/agence_societe/agence_individuelle/syndic_copropriete + fallback (`agence`/`autre_societe`/franchise/inconnu → set complet). N/A : `particulier` (sections `[]`, non-Hoguet) + `prestataire_services` (`isHoguetCompliant()=false` → capability bloque #120, letterhead masque Carte T via `$isPro`). **2ᵉ gap trouvé+corrigé** : `agent_commercial`/`agent_salarie` sont Hoguet-compliant (`isHoguetCompliant()` = statut ∉ {particulier,prestataire_services} → tier `pro`, `hoguet.docs=1`) mais avaient des sections réduites (`['identity','rcp','preview']` / `['identity','preview']`) → un tel agent SANS agence active (notice perso lue par `buildHoguetVariables`, car avec agence c'est la notice agence qui sert) ne pouvait pas saisir carte_t/garantie/mediateur → 13 placeholders sur #120 (vérifié user 214284). Fix = set Hoguet complet pour ces 2 statuts (blade + `setting-agency.js`). Tous les modes Hoguet-compliant peuvent désormais remplir les 13 mentions. **Note** (cross-mode, inchangé) : `buildHoguetVariables` met `$req()` sur garantie_financiere_provider/number/amount **indépendamment de la case `has_garantie_financiere`** → un pro sans garantie qui laisse vide voit quand même le placeholder (politique existante, identique à independant/agence — hors scope de ce fix). **→ CORRIGÉ le 2026-06-29 (3ᵉ passe)** : la garantie n'est obligatoire que si le pro **manie des fonds** (art. 3 loi 70-9) ; un négociateur sans maniement de fonds doit afficher « ne reçoit aucun fonds », pas un placeholder impossible. Core-city-user `buildHoguetVariables` expose `agent_garantie_financiere_block` (phrase complète, mode-aware) + `agent_garantie_financiere_inline` (variante prose sans majuscule/point) + `agent_has_garantie_financiere` (cf. `core-city-user/CLAUDE.md`). **16 templates système migrés** (UPDATE direct dev, scripts rejouables `.insights/garantie-block-2026-06-29/apply_garantie_block.php` [footers/{{#if}} → `{{agent_garantie_financiere_block}}`, 16 ids] + `apply_garantie_inline.php` [clauses prose « …Carte T n° X, garantie par/financière souscrite… » des #71/#72 → `{{agent_garantie_financiere_inline}}`]). **#73 (mandat_gestion) EXCLU volontairement** (gestion locative = maniement de fonds toujours requis → garantie obligatoire, placeholder légitime ; garde ses vars brutes). E2E (`checkDocumentCompleteness` + rendu) : pro sans fonds = « Ne reçoit aucun fonds… » + **0 placeholder garantie** sur les 16 ; pro avec fonds rempli = clauses complètes ; pro avec fonds + champ vide = placeholder provider légitime (verrou e-sign actif). ⚠ Les **forks utilisateur** des templates conservent l'ancienne ligne multi-variables (rétro-compat, comportement imparfait inchangé). **PIÈGE rencontré** : un même template (71/72) peut avoir DEUX références garantie (footer + clause prose inline) de formats différents → balayer `agent[._]garantie_financiere_(provider|number|amount)` sur CHAQUE template après migration pour vérifier `raw_vars=0`.
- **Bloc identité « L'Agence » du #120 adapté au mode (2026-06-30)** : 2 bugs signalés sur le document « Informations précontractuelles » — (1) **parenthèses vides** « L'agence My Agenciesss **()** est située… » quand la forme juridique/capital manque ; (2) **vocabulaire « agence » figé** alors qu'un mandataire réseau n'est pas une agence (« L'agence Julien Villegas () est située… »). Le mot « agence » était présent **9×** dans le #120 (heading « L'Agence », intro, « Immatriculée » fém., « l'activité de l'agence », « confierez à l'agence », « rémunération de l'agence », « L'agence déclare », « l'établissement de l'agence », « que l'agence commence », case signature « L'agence - »). Core-city-user `buildHoguetVariables` expose des variables mode-aware (`agent_entity_*` + `agent_identity_intro` + `agent_legal_form_block`, cf. `core-city-user/CLAUDE.md`). Template #120 migré (UPDATE dev, script rejouable `.insights/garantie-block-2026-06-29/apply_identity_mode_120.php` : 10 remplacements, chacun vérifié à exactement 1 occurrence + garde « 0 résidu agence »). **Scope** : parens vides + heading « L'Agence » sont **UNIQUES au #120** (les « agence » des autres templates = expressions figées « honoraires d'agence »/« barème de l'agence » ou #109 déjà « Le mandataire (agence) » → laissés). E2E rendu réel : user 2 (agence) → « L'Agence » + « L'agence My Agenciesss est située… » (0 parenthèse) ; user 214302 (réseau) → « Le mandataire » + « Le mandataire Julien Villegas, affilié au réseau Dr House Immobilier, est domicilié… », « Immatriculé », « du mandataire », **0 occurrence « agence »**, #120 placeholders=0. Mapping 3-voies (agence/mandataire_reseau/professionnel-pour-le-reste). Pas de rebuild (PHP trait PSR-4 + template BDD).
- **Audit « tous modes / tous documents » — champs Hoguet optionnels gardés (2026-06-30)** : suite de la demande « gère bien tous les modes sur tous les documents », audit de TOUS les ~106 templates système (`audit_mode_wording.php` + `audit_optional_vars.php` + `detect_patterns.php`). Le wording « agence » figé (heading « L'Agence » + intro « L'agence X (forme) est située ») était **UNIQUE au #120** (déjà corrigé). Mais plusieurs champs **optionnels** (rendus '' si vides : `agent_mediateur_url`, `agent_rcp_amount`, `agent_head_office_city`, `agent_capital_social`) figuraient dans des parenthèses/labels/séparateurs SANS `{{#if}}` → rendus cassés **dans tous les modes** quand vides : « médiateur NAME **()** » / « NAME **-** » (séparateur orphelin) / « **(montant garanti :  €)** » / « **Fait à , le** » / « NAME **- capital  €** ». Corrigés par gardes `{{#if}}` (sûres : ces vars valent '' si vides — pas `$ph`) via scripts rejouables `.insights/garantie-block-2026-06-29/apply_optional_guards.php` (4 patterns × 20 templates : médiateur parens #71/#120, médiateur séparateur 14 baux/mandats, RCP montant garanti 14, « Fait à {ville} » 20) + `apply_capital_guard.php` (capital orphelin #109/#112). **Convertit aussi la notation à points `{{agent.x}}` → underscore** (templates 74/77). E2E : rendu des **22 templates** pour un mandataire réseau avec TOUS les optionnels vides → **0 pattern cassé** (`()`/`- ,`/`(montant garanti :  €)`/`Fait à ,`/`- capital  €`). ⚠ PIÈGE : les détections regex `({{agent_mediateur_url}})`/`(montant garanti…)` re-flaggent en FAUX POSITIF après coup (le littéral existe toujours, désormais enveloppé dans `{{#if}}`) → la vérité = le **rendu** (`replaceModelVariables`), pas le grep. Les `{{agent_company_legal_form}} au capital de {{agent_capital_social}}` des baux sont DÉJÀ guardés par `{{#if agent_capital_social}}` (hors fenêtre de détection courte) → laissés. `agent_email` (RGPD #112) non gardé mais fallback email du compte → jamais vide → laissé.

- **🟥 Le statut professionnel divergeait entre le Profil et la Conformite (2026-08-26)** — signale : « je passe en *Mandataire reseau* depuis Profil, le profil l'affiche bien, mais l'onglet Conformite reste sur *Agence societe*, la section « Reseau d'affiliation » n'apparait pas, et le champ `gmnExternalNumber` manque dans `modalGenerateMandat` ». **Les trois symptomes ont la MEME cause** : la conformite (`SettingController::list` → `confNotice`), la modale mandat (`_modal_generate_mandat.blade.php`) et le registre lisent tous `MandateRegistry::resolveLegalEntityForUser`, dont la resolution etait **arbitraire** et acceptait les fiches **archivees** — detail des 4 defauts dans `core-city-user/CLAUDE.md`. ⚠ **« Je ne reproduis pas en local, mais en production si »** s'explique : sans `orderBy`, PostgreSQL choisit librement parmi les notices candidates, et l'ordre physique change apres chaque UPDATE (MVCC) — le sens de la divergence n'est donc pas stable. Mesure du 2026-08-26 : **2 comptes sur 3** a notices multiples portaient des statuts divergents. **Cote site** : `resources/views/vendor/core/user/account_profile.blade.php` avait sa **propre lecture** de la notice, avec un predicat different qui acceptait `user_legacy` — elle delegue desormais a `resolveLegalEntityForUser` (source unique), avec repli local si le package n'est pas monte. ⚠ Cette vue est un **override local** : le tag `core` ne publie que `public/core` (verifie), elle n'est donc **pas ecrasee** par le `post-autoload-dump` — contrairement aux annotations Swagger. ⚠ `updateMode` cree legitimement une **nouvelle** fiche `scope='user'` quand la precedente a ete archivee (`firstOrNew` inclut `scope` dans le critere) : c'est ce qui produit la coexistence archive + active, et ce n'est pas un defaut — c'est la LECTURE qui devait trancher. **Verifie** : les 3 ecrans en session reelle sur le compte au cas exact (214307) — badge profil « Mandataire affilie a un reseau · Reseau : Dr Hous Immobilier », bandeau conformite aligne, section reseau rendue avec son `network_name`, et champ « N° du mandat au registre de votre reseau (Dr Hous Immobilier) » present dans la modale. **0 route, 0 migration, 0 JS, 0 SCSS.** Controle d'ampleur rejouable en prod (lecture seule) : `.insights/audit-legal-notices-2026-08-26.php` ; harness `legacy`/`fixed` : `.insights/legal-notice-resolution-2026-08-26.php`.
- **🟥 Hero NBA : « Ajouter un contact » propose alors que le contact est deja la (2026-08-25)** — signale juste apres le lot d'auto-completion : sur un lead capte par un widget d'estimation, la tache « Appeler le prospect » reste (a juste titre) a faire, mais `.ld-hero-nba .lhn-actions` proposait **« Ajouter un contact »** alors que le contact venait du formulaire. **Cause** : `lead_nba_resolve_action` derive l'action de l'`auto_complete_on` de la tache **sans jamais verifier si la condition est deja satisfaite** - `contact_linked` rendait donc toujours l'action qui PRODUIT cet etat. **La garde existait deja**, mais uniquement dans la branche de repli par le TITRE de `lead_next_best_action` (`$guessed === 'contact_linked' && lead_nba_has_contact()`) : elle ne voyait jamais les taches qui portent reellement un `auto_complete_on`, c'est-a-dire **celles des presets, soit le cas nominal**. **Livre** : la substitution vit desormais dans **`lead_nba_resolve_action`**, au meme endroit que la branche « document deja genere → suivre au lieu de re-generer », donc elle couvre TOUS les chemins (tache, titre, prerequis, trigger, echelle d'etat) ; le doublon de la branche titre est retire (une seule regle, un seul endroit). Nouveau helper **`lead_nba_reach_contact_action($lead, $event)`** : le geste attendu n'est pas de rattacher un contact mais de le **JOINDRE**. ⚠ **Le canal propose depend du PLAN** : `ld-open-log-call` (modale de log d'appel, qui trace l'echange et cree la relance) **uniquement avec `ACCESS_CRM`** - sa route `POST /acquisition/lead/{id}/call/log` est en `planaccess:ACCESS_CRM`, soit **2 plans sur 40** ; sans CRM, renvoi vers `[data-section="contacts"]` de l'onglet **Pilotage**, le seul jamais gate. ⚠ **La garde teste `checkSingleCondition($lead, $event)`, pas la simple presence d'un contact** : un contact rattache **sans email ni telephone** laisse « Renseigner les coordonnees » proposee, ce qui est correct. ⚠ `_lead_hero_nba_button.blade.php` rend un `data-contact-id` (le handler `.ld-open-log-call` l'accepte en option et attribue l'appel au bon interlocuteur). **Defaut latent corrige au passage** : `CheckPlanAccess::$memo` etait **statique sans l'identifiant de l'utilisateur** - dans un process qui itere sur plusieurs comptes (commande, worker, harness), le verdict du PREMIER servait a tous les suivants, donc un Basique heritait **en silence** des capacites d'un CRM ; la cle porte desormais `auth()->id()`. **Verifie** : harness `legacy`/`fixed` qui **reproduit d'abord le defaut** (`.insights/nba-contact-already-linked-2026-08-25.php` - **15/15** en fixed, dont les deux branches de plan testees sur de vrais comptes 214295/CRM et 214297/Premium, et deux contre-epreuves : lead sans contact → « Ajouter un contact » conservee, contact sans coordonnees → « Renseigner les coordonnees » conservee) ; **rendu serveur des 3 leads reels** qui presentaient le symptome (868, 1550, 1557) → 0 occurrence de « Ajouter un contact » ; **E2E navigateur** sur un lead fabrique puis supprime : bouton « Appeler le contact » porteur du bon `data-contact-id`, **clic reel** → modale de log d'appel ouverte avec lead et contact transmis ; PHPUnit **147 tests / 6381 assertions**, les 3 echecs restants etant les memes preexistants. 2 tests de garde ajoutes a `LeadNbaResolverTest` (19/19). **0 route, 0 migration, 0 JS, 0 SCSS, 0 fichier de package** → aucun tag, aucun `composer update`, aucun rebuild ; `view:clear` suffit.
- **Auto-completion des taches a la creation d'un lead (2026-08-25)** — le defaut et son correctif vivent dans les packages (`core-city-user/CLAUDE.md` : service `LeadTaskAutoCompleter` ; `core-city/CLAUDE.md` : sequence des 5 widgets). Cote site :
  - **Tests de garde** dans `tests/Feature/PipelineAcceptedRequirementTypesTest.php` : `test_createLeadManual_fires_qualification_events_post_creation` (mis a jour — les fires ont ete deplaces dans le service, le test garde les deux bouts), plus deux nouveaux — `test_all_widgets_run_post_create_sequence_after_task_creation` (les 5 controllers appellent la sequence, **et apres** `LeadStatusEntryHandler`, verifie par comparaison de positions dans le source) et `test_contact_linked_is_never_reconciled` (garde-fou produit).
  - **Harness rejouables** dans `.insights/` : `lead-task-autocomplete-ab-2026-08-25.sh` (A/B complet, restaure les fichiers d'origine dans le vendor le temps du run **legacy** — sans ca une transition repasse par le handler corrige et le defaut se corrige tout seul), `lead-task-autocomplete-2026-08-25.php` (scenarios `legacy|fixed`), `lead-task-autocomplete-e2e-2026-08-25.sh` (vraies routes HTTP des widgets), `lead-task-autocomplete-manual-2026-08-25.php` (non-regression du flux manuel), `measure-task-autocomplete-gap-2026-08-25.php` (mesure), `backfill-lead-task-autocomplete-2026-08-25.php` (**rattrapage, dry-run par defaut**).
  - ⚠ **Les routes `/api/v1/widget/*` exigent un `Authorization: Bearer <cle API scope widget>`** (le `data-token` que l'agent colle dans son site) : sans lui, **401 `unauthenticated`** et non une capture anonyme. Les cles de test sont dans `core.oauth_api_keys` (`scopes @> '["widget"]'`). `POST /api/v1/widget/estimate` exige aussi `insee_code`.
  - ⚠ **`getInitialStatusForLead` prend le 1er statut `active` par `display_order` qui accepte le type** : un compte au pipeline hybride (statuts legacy « A prospecter » a `display_order = 0`, `accepted_requirement_types` NULL) voit **tous** ses leads y atterrir, donc sans aucune tache. Ce n'est pas un defaut d'auto-completion — verifier le pipeline du compte avant de conclure.

### Fichiers memory detailles
- [architecture.md](.memory/architecture.md) — stack, configs, providers, middleware, bootstrap, i18n
- [models.md](.memory/models.md) — 5 modeles locaux (Filter, User)
- [database.md](.memory/database.md) — 20 migrations, 4 seeders
- [controllers.md](.memory/controllers.md) — 69 controllers (23 main + 12 tabs + 34 API Swagger) — Sprint C : +9 méthodes SettingController + 2 ClientPortalPublicController
- [routes.md](.memory/routes.md) — ~175 routes web + API v1 (Sprint C : +11 routes)
- [views.md](.memory/views.md) — 194 fichiers Blade, 20 sous-dossiers (Sprint C : +3 vues)
- [frontend.md](.memory/frontend.md) — 131 JS + 55 SCSS + 3 widgets (62 modules src + 3 builds + 3 rollup configs)
- [marketing.md](.memory/marketing.md)

### Documentation Docusaurus : URL canonique et barre finale (2026-11-03, plan SEO de la vitrine)

- `resources/docusaurus/docusaurus.config.js` : `url` = `process.env.DOCUSAURUS_SITE_URL` (repli `getAppUrl()`) et
  `trailingSlash: true`. Avant, la canonique et le plan du site de la doc pointaient vers le domaine de l'application.
- `config/docusaurus.php` transmet `DOCUSAURUS_SITE_URL` (défaut `https://docs.infoparcelle.fr`) ; `.env` de dev :
  `http://docs.devinfoparcelle.fr`. Rebuild dev : canonique `http://docs.devinfoparcelle.fr/resources/faq/`, 22 URL du
  plan du site en 200.
- Déploiement : `DOCUSAURUS_SITE_URL=https://docs.infoparcelle.fr` dans le `.env` de prod, puis
  `docker exec docker-rs-1 bash -lc "cd /var/www/rs_infoparcelle_application && php artisan docusaurus:build"` (en root,
  `bash -lc` pour nvm). ⚠ Ne pas commiter l'`openapi.json` de dev (il annonce l'adresse ngrok comme serveur).

### Documentation Docusaurus - correction MCP (2026-08-20)

- 🟥 **La doc vivante est ici** : `resources/docusaurus/` (servie sous **`/api/guide/`**,
  `config/docusaurus.php`). Le projet racine **`/home/julio/www/Infoparcelle-Docusaurus/`
  est OBSOLETE** - ses fichiers sont des copies a l'octet pres (verifie : `docs/sdks/mcp.md`
  identique, 14 289 o, 18/11/2025). **Ne jamais y editer.**
- `docs/sdks/mcp.md` imposait une etape **inexistante** : « creer un client OAuth2, callback
  `https://mcp.infoparcelle.fr/oauth/callback`, recuperer Client ID et Secret ». Le serveur
  fait de l'enregistrement dynamique (RFC 7591) et le formulaire de Claude n'a plus ces
  champs. Remplace par le parcours reel (adresse du serveur + connexion), avec le detail
  **releve a l'ecran** pour Claude.
- ⚠ **L'URL a communiquer est la RACINE `https://mcp.infoparcelle.fr`** (le protocole est
  monte sur `/`, `/mcp`, `/sse`, `/ws`).
- ⚠ Un encadre **scopes** a ete ajoute **uniquement** dans la section « cle API
  auto-hebergee » : `cadastre` + `person` obligatoires, sinon 403. La voie OAuth du serveur
  heberge n'est pas scopee.
- ⚠ **`docs.infoparcelle.fr` sert un build STATIQUE** (Apache, sans `x-powered-by`, dernier
  build du 2026-08-19) : verifier ce qui l'alimente avant de considerer la correction
  deployee. `app.infoparcelle.fr/api/guide/sdks/mcp` repond 200.
- 🟥 **`/api/guide/` est CASSE en production : la doc s'y affiche sans aucun style.**
  `docusaurus.config.js` declare `baseUrl = '/api/guide/'` par defaut, mais le build deploye
  a ete produit avec **`DOCUSAURUS_BASE_URL=/`** (pour le domaine dedie). Resultat mesure le
  2026-08-20 : la page `/api/guide/sdks/mcp` reference `/assets/css/styles.87812b5d.css`
  -> **404** sur `app.infoparcelle.fr`, alors que `/api/guide/assets/css/styles.87812b5d.css`
  repond **200**. **Deux corrections possibles** : rebuilder avec `DOCUSAURUS_BASE_URL=/api/guide/`,
  ou retirer la route et ne garder que le domaine dedie. **En attendant, ne lier ce chemin
  depuis nulle part** - la doc publique est **`https://docs.infoparcelle.fr`** (prod) /
  **`http://docs.devinfoparcelle.fr`** (dev), ou tout repond 200 avec les styles.
- ⚠ `resources/docusaurus/static/openapi.json` (modifie le 2026-08-19, hors de ce lot)
  annonce **`http://35bd82292b7d5177.ngrok.app`** comme « API Production Infoparcelle » :
  a corriger AVANT tout rebuild de la doc, sinon la reference publie une URL de tunnel de dev.
- **Doc MCP : l'auto-hebergement a ete RETIRE (2026-08-20, decision utilisateur).**
  🟥 Le depot `github.com/Rootsud/Infoparcelle-MCP` est **prive** (127 commits, 18 branches
  distantes : il a bien ete pousse, mais il repond **404** sans authentification). La doc
  publiee y renvoyait **6 fois** : `git clone`, lien depot, lien issues, « forkez le
  projet », et un lien `LICENSE` **alors qu'aucun fichier LICENSE n'existe** malgre le badge
  MIT du README. **102 lignes decrivaient une option inaccessible.**
  Retire de `docs/sdks/mcp.md` (518 -> 355 lignes) : section « Option 2 : Auto-heberge »,
  « Test avec MCP Inspector », « Developpement », « Contribution », « Licence », badge MIT ;
  « Deux methodes d'utilisation » devient « Connexion ». Idem dans `docs/sdks/overview.md`
  (bloc MCP Server) et `docs/getting-started/authentication.md` (l'exemple « Claude MCP
  Server » imposait `INFOPARCELLE_CLIENT_ID`/`SECRET`, **inutiles** et non lus par
  `mcp-remote`).
  ⚠ **Decision produit** : ne PAS publier le depot. Il n'apporte rien au parcours client
  (le serveur est heberge), l'auto-hebergement impose une **cle API scopee en refus par
  defaut** (`cadastre` + `person`, sinon 403) donc du support pour zero revenu, et le depot
  contient de l'exploitation a assainir avant toute publication : IP de production
  `162.19.28.220`, vhost, unite systemd, `deploy/RUNBOOK-PRODUCTION.md`, `EXPLOITATION.md`
  (qui **raconte une panne de securite**, dont les 10 clients OAuth non revoques), chemins
  `/home/julio/`, noms de conteneurs, URLs ngrok, et 5 rapports d'analyse internes.
  **Aucun secret reel dans l'historique** (verifie : uniquement des placeholders).
  ⚠ **NON TRAITE, hors perimetre** : les depots des SDK **PHP et TypeScript sont AUSSI en
  404** (`Rootsud/Infoparcelle-SDK-PHP`, `Rootsud/Infoparcelle-SDK-TS`, plus des liens sous
  `Julio-rootsud/` : deux organisations melangees). Il reste **9 liens GitHub morts** sur
  `/sdks/overview` et les pages SDK, et les commandes d'installation annoncees
  (`composer require infoparcelle/php-sdk`, `npm install @infoparcelle/sdk`) ne
  correspondent a aucun package publie (API Packagist et registre npm en **404**).


### 🟥 Adresses du CRM : cle BAN au lieu de l'id serie de fr_adresses (2026-09-02)

Contexte et livrables de package dans `core-city/CLAUDE.md` et `core-city-user/CLAUDE.md`. Cote site :
- `ParcelUserController::selectAddress` : pour `source = Fr_adresse`, `sourceId` passe par `Fr_adresse_ban::keyFrom()` (cle BAN ; un id serie inconnu -> **400 « Adresse introuvable »**) ; l'autre site d'ecriture (`Parcel_user_addresse`) stocke `getKey()`. `object_type` reste le FQCN, compare tel quel par les 3 blades et `TabController` (chaines).
- `ActionController::saveEstimationData` : `adresse_id = Fr_adresse_ban::keyFrom(address_source_id)` pour `address_source = Fr_adresse`, pass-through pour les autres types.
- `AppServiceProvider` : morphMap des adresses RETIREE (declaree une fois dans `CityServiceProvider`). Idem `administration`. Elle ne connaissait pas l'alias `Parcel_user_addresse` (2 estimations en prod) : desormais resolu.
- ⚠ **Piege d'environnement dev** : `pgsql_city` du site = base 5430 (prod-size), `pgsql_city_user` = docker-db-1 ; les lignes CRM de dev avaient ete ecrites contre la base locale -> par id serie elles rendaient sur 5430 des adresses du Puy-de-Dome (defaut en conditions reelles). La migration de conversion a ete jouee depuis administration (mapping local, 96 par id + 3 par slug) ; par cle BAN la fiche lead 1895 rend correctement « 1 Route de Bages · 66180 · Villeneuve-de-la-Raho » sur 5430.
- Verifie : harness `.insights/address-morph-ban-2026-09-02.php` (legacy 4/4 defaut reproduit, fixed 28/28, base rendue a l'identique), E2E en session reelle (fragment `POST /parcel` rend `data-source-id="66136_0356_00006"`, `POST /parcel/address/select` 200 avec la cle BAN ecrite, id inconnu 400, `POST /action/parcel/estimation/save` 200 avec `adresse_id` BAN - apres correction du 500 de `getCurrentAddresse`), variables `dest_*` de `HasModel` sur un lead reel, PHPUnit 153/3 echecs preexistants, 0 erreur console, 0 ligne de log imputable, lignes de test supprimees.
- **Deploiement** : tag core-city + core-city-user + core, `composer update` sur rs_infoparcelle_application ET administration (captimmo/rootsud EXCLUS), `php artisan migrate` (conversion + FK) depuis administration, `queue:restart`, `view:clear`, **puis seulement** le rafraichissement core_city/core_person. Avant : `.insights/mesure-adresses-morph-prod-2026-09-02.sql`.

### 🟥 POST /estimate en 500 sur la cle BAN : le bloc adresse de `EstimationController::calculate` n'avait pas suivi la bascule du 2026-09-02 (2026-09-17)

Signale : « quand je mets a jour la surface dans `cardEstimateSurface`, le POST `/estimate` part mais 500 `invalid input syntax for type bigint: "56081_zulpt8_00006"` sur `select * from fr_adresses where id = ...` ». **Cause** : le switch `address_source` de `calculate()` (l. 160-190) est une COPIE du bloc de `PdfParcelsAvis::getCurrentAddresse` (core-city) ; le lot du 2026-09-02 a corrige l'original (`Fr_adresse_ban` + `keyFrom` + colonne `adresse_ban_id`) mais pas la copie du site, restee en `Fr_adresse::where('id', <cle BAN>)`. Depuis, chaque recalcul d'estimation dont le front porte une adresse de la base plantait (le fragment rend `data-source="Fr_adresse"` + `data-source-id="<BAN>"`). ⚠ Ce bloc alimente `address_selected`/`address_selected_label` que **aucune vue rendue par `calculate()` ne consomme** (`card_estimate_unified_modern` seul est rendu) : la lecture ne servait qu'a planter.
- Livre : `calculate()` aligne sur `getCurrentAddresse` (`Fr_adresse_ban`, `adresse_ban_id`, `keyFrom()` qui convertit un id serie d'un front perime et rend null sinon ; cle vide = pas de lecture) + `address_selected_source` / `address_selected_key` transmis a la vue, et `card_estimate_tab_estim_address.blade.php` les prefere a `get_class()` / `->id` (qui auraient renvoye `Fr_adresse_ban` et l'id serie au POST suivant, inconnus de `selectAddress` et de `saveEstimationData`).
- Verifie : 500 reproduit a l'identique sur la vraie route en session curl (cle BAN du 66), puis 5 scenarios en 200 (cle BAN, id serie, cle inconnue, id inconnu, sans adresse), **E2E navigateur** : fragment reel `POST /parcel/tab/estimation` injecte, saisie dans `cardEstimateSurface` → POST `/estimate` du JS du site avec `address_source=Fr_adresse&address_source_id=66136_3667_00009` → **200** + module prix rendu ; 0 erreur console, 0 nouvelle ligne de log, PHPUnit 153/3 echecs preexistants. Aucun fichier de core : `view:clear` suffit.
- ⚠ Lecon : un correctif applique a un bloc **duplique** doit etre cherche dans les copies par la chaine de classe (`grep -rn "Models\\France\\Fr_adresse'"`), pas seulement par le nom de methode. Copies restantes du meme switch : `ParcelUserController::selectAddress` (deja correct), `PdfParcelsAvis::getCurrentAddresse` (correct).

### 🟥 Alerte de recherche sauvegardee sans zone geographique (2026-09-04)

Cause, matcher et harness dans `core-advert/CLAUDE.md`. Cote site :
- `SavedSearchController::createOrUpdateAlerts` : si un canal est demande mais que
  `Rootsud\Advert\Alerts\SavedSearch::hasGeographicScope($savedSearch->properties)` est faux, **aucune
  `User_alert` n'est creee** (log INFO `[SavedSearch] alerte sans perimetre geographique`). Les colonnes
  `alert_push` / `alert_email` gardent le choix de l'utilisateur : des qu'il re-enregistre la recherche
  avec un lieu (`overwrite`), l'alerte est creee normalement. Les alertes existantes ne sont pas touchees,
  c'est le matcher qui les neutralise.
- Modale `saved_search/modal/create_saved_search.blade.php` : encart `#alertNoZoneWarning` (« Aucune zone
  geographique n'est selectionnee ... Aucune notification ne sera envoyee ») affiche par
  `updateAlertNoZoneWarning()` (`saved_search.js`) des qu'un canal push ou email est coche sans zone,
  masque sinon ; appele a l'ouverture, au toggle et au reset. **Non bloquant** (decision utilisateur).
- `window.savedSearchHasGeoZone()` : miroir JS de la regle serveur, lu **sans effet de bord** sur
  `#map-search-input` (`data-layer` + `data-code`) et `getDraw()` - contrairement a `getCity(true)` qui
  recopie les attributs sur `#resultList`.
- Verifie en session reelle (bundle `npm run prod`) : 7 etats de la modale (push / email, avec et sans
  departement, secteur sans code), enregistrement reel sans zone -> `saved_searches` creee avec
  `alert_push = true` et **0 `user_alerts`** + ligne INFO ; enregistrement avec departement 09 ->
  `user_alerts` creee (non-regression) ; 0 erreur console ; lignes de test supprimees.
- ⚠ Pourquoi le lieu du client n'a pas ete enregistre reste **non reproduit** : la sauvegarde lit les
  attributs `data-layer` / `data-code` du champ carte ; l'affichage d'un « secteur » sans ces attributs
  n'a pas ete retrouve. L'avertissement rend au moins l'etat visible avant d'enregistrer.
- Deploiement : `npm run prod` + `view:clear` cote site ; tag core-advert + `composer update`
  (rs_infoparcelle_application ET administration) + `queue:restart`.

### Grille API V14 : doc Docusaurus, copies Swagger et harness (2026-09-10)

- Doc `resources/docusaurus/docs` : `getting-started/authentication.md`, `getting-started/rate-limits.md` (nouveau tableau « Cout des appels » 0 / 1 / 2 / 10 / 20, debit **par appel** quel que soit le nombre de resultats), `resources/faq.md`, `api-reference/overview.md` → grille V14 (Essai API 2 000 / 14 j ; Starter 25 / 5 000 ; Business 100 / 25 000 ; Pro 200 / 50 000 ; Enterprise 750 / 300 000 et Scale 1 250 / 625 000 sur devis ; packs 9,90 / 39,90 / 69,90 ; hors-forfait 0,008 €/credit opt-in ; offres app cumulables avec un plan API). Rebuild : `l5-swagger:generate` → `docusaurus:generate --force` → `docusaurus:build` **en root avec `bash -lc`** (en `www-data` la copie de `static/openapi.json` echoue en Permission denied et Node est absent du PATH), puis `chown` de `storage/`. Servi sur `docs.devinfoparcelle.fr` (OpenAPI : 9 operations a « 2 crédits »).
- Copies Swagger `app/Http/Controllers/ApiV1/CoreCity/Virtual/{CTA_Geocoder,CTC_Territoire}Controller.php` recopiees depuis core-city (fichiers root : `docker exec cp`).
- **Harness rejouable** `.insights/tarifs-api-v14-2026-09-10.php` (tinker, etapes `trial-app / check-trial / business / pack / exhaust / status / cleanup`) + `.sh` (appels HTTP reels `GET /api/v1/dvfs`, `/geocoder/search?recherche=`, avec une vraie cle : `User::createToken` + `ApiKey` mappee - une cle sans token Passport rend 401 `token_not_found`). Exige un compte de test cree a la main (email verifie) ; un abonnement fabrique a besoin d'une `billing_addresses` et d'un `billing_means` (FK NOT NULL) ; `billing_plan_payments.reference` est un entier. Au nettoyage, un compte qui a ouvert `/tarifs` connecte porte un pipeline auto-seede (`lead_statuses` FK vers `users`) a purger avant `forceDelete`.
- ⚠ `/compte/api` est servi par **`ApiAccessController::index`** (route du core `GET /api` du groupe compte), PAS par `UserController::account` : les deux lisent desormais `Helpers\ApiCreditSummary`. `view:clear` requis apres modification d'une vue du core chargee en override PSR-4.

### Creation d'une agence ou d'une equipe : atterrissage sur la page de la structure (2026-09-18)

**Demande** : « quand on cree une agence ca redirige vers les parametres de conformite, il faudrait que ca redirige
vers la page de l'agence comme `/compte/equipe/260` ; meme chose quand on cree une equipe ».

**Avant** : le wizard `#formAgencyCreate` (`setting-agency.js`) traitait les deux cas differemment - une **agence**
posait `localStorage.settings_active_tab = 'conformite'` puis naviguait vers `/parametres?entity=group:{id}`, une
**equipe simple** appelait `reloadSettingsTab()` (on restait sur l'onglet courant des parametres, sans jamais voir
la structure creee).

**Livre** : les deux endpoints repondant deja `{ group_id }` (`AgencyController::create` et `::createGroup`), le
handler redirige desormais vers **`/compte/equipe/{group_id}`** dans les deux cas (navigation pleine : ce n'est pas
une route du split-panel), avec repli sur `reloadSettingsTab()` si la reponse ne portait pas l'identifiant.
Cote serveur, la cle `redirect` de `create()` **et** celle de `convertTeamToAgency()` pointent elles aussi sur
`/compte/equipe/{id}` : `resources/views/vendor/core/user/account.blade.php` la consomme (`res.body.redirect ||
fallback`), donc « Transformer en agence » atterrissait lui aussi sur la conformite.

⚠ **La fiche Hoguet n'est pas perdue de vue** : le hub agence porte deja son bouton « Conformité »
(`compte/agence/manage.blade.php`), verifie present sur la page d'arrivee.

⚠ **NON touche, volontairement** :
- `AgencyController::updateMode()` garde `'/parametres?tab=equipe&action=create-agency'` : a ce stade **aucune
  structure n'existe encore**, cette redirection sert justement a ouvrir le wizard.
- `welcome-onboarding.js` cree la structure puis enchaine sur l'etape 3 de son propre parcours guide (`go(3)`) :
  y injecter une navigation ejecterait l'utilisateur au milieu de l'onboarding.

**Verifie** dans le navigateur, parcours reels sur le compte de test, structures supprimees ensuite :
- equipe simple → `POST /parametres/equipe/creer` 200 `{group_id:262}` → atterrissage **`/compte/equipe/262`**,
  nom de l'equipe rendu ;
- agence → `POST /parametres/agence/creer` 200 `{group_id:263}` → **`/compte/equipe/263`**, nom rendu,
  bouton « Conformité » present ;
- transformation d'une equipe en agence → **`/compte/equipe/262`** (avant : onglet Conformite) ;
- bundle servi controle (nouveau code present, ancien `settings_active_tab='conformite'` absent),
  0 erreur console, 0 ligne de log.

⚠ **Deploiement** : `git pull` + **`npm run prod`** (le handler vit dans `public/js/app.js`) + `view:clear`.
0 migration, 0 route, 0 fichier de package.

### Preferences : « Email courtoisie pro J-1 » retire, SMS H-2 desactive par defaut (2026-09-18)

**Demande** : « verifier a quoi correspond Email courtoisie pro J-1 dans le bloc Rappel automatique aux clients et
s'il faut le garder » + « desactiver par defaut le rappel auto par SMS ».

**Ce qu'etait le courtoisie pro J-1** (analyse avant toute modification) : un e-mail **par rendez-vous** envoye au
professionnel la veille (objet « Demain : RDV avec ... »), alors que le libelle de l'ecran annoncait « votre recap
de demain ». Le **Recap quotidien**, juste en dessous dans le meme ecran, rend deja la liste des RDV du jour en un
seul e-mail et couvre tous les RDV, pas seulement ceux lies a un lead. **Decision utilisateur : supprimer.** Le
moteur vit dans core-city-user (cf. son CLAUDE.md) ; cote site : toggle et variable `$rdvJ1Courtesy` retires de
`setting/tabs/preferences.blade.php`, cle retiree de `AgencyController::PREFERENCE_KEYS` (propagation agence ->
membres et reset des preferences perso).

**SMS H-2 desactive par defaut** : `$pget('rdv_reminder_h2_client_enabled', '0')`. ⚠ **Ce defaut doit rester
aligne sur celui de `RdvReminderCommand`** (core-city-user), qui decide de l'envoi reel - sinon l'ecran affiche
« desactive » pendant que le SMS part, ou l'inverse. Le libelle porte desormais « (decompte de vos credits SMS) » :
le SMS consomme le quota `SMS_CREDIT` (100/mois sur le plan CRM), il faut que le choix soit eclaire.
⚠ **L'effet porte sur tout le parc** (presque aucun compte n'a de ligne en base pour ce reglage) : le SMS s'arrete
pour tous, sauf ceux qui l'ont explicitement active - c'est l'effet recherche.

**Template #95 archive** : `.insights/archive-template-rdv-courtoisie-2026-09-18.php` (idempotent, `DRY_RUN=1`
disponible, **A REJOUER EN PROD**). Il n'est plus lu par aucun code ; `archived = 1` le sort des selections sans
detruire un contenu redige. ⚠ **Donnees, pas une migration** : la table `modeles` part en production par
copier-coller, les migrations de donnees y sont proscrites. ⚠ Les reglages `rdv_reminder_j1_pro_courtesy` restes
dans `core.user_settings` sont **volontairement conserves** (inoffensifs, plus aucun code ne les lit).

**Verifie** : A/B `.insights/rdv-reminder-defaults-2026-09-18.sh` (legacy 4/9 - le SMS partait sans aucun reglage -
puis **fixed 9/9**), fragment `POST /parametres` rendu en session reelle (**2 toggles**, SMS **decoche**, mention
des credits, **0 occurrence** de `rdv_reminder_j1_pro_courtesy` dans toute la page), script d'archivage joue puis
rejoue (idempotent), PHPUnit **157 passes / 3 echecs preexistants**, 0 erreur console, 0 ligne de log imputable,
base rendue a l'identique.

⚠ **Deploiement** : tag core-city-user + `composer update` (ce site ET administration) + `queue:restart`, puis
`git pull` + `view:clear` + le script d'archivage. **0 migration, 0 route, 0 JS, 0 build.**

### Onglet « Biens similaires » de l'estimation : termine, et relie aux pages « biens vendus / en vente » de l'avis de valeur (2026-09-18)

**Demande** : decommenter `#tab-biens-similaires` (fiche parcelle > Estimation), le terminer a 100 %, choisir les DVF et les
annonces autour de la parcelle, retrouver par defaut la meme selection automatique que le PDF, repercuter toute modification
dans le PDF, et recharger la selection a la reouverture de la fiche. Decisions utilisateur : annonces « En vente » visibles avec
`ACCESS_ESTIMATE` (meme regle que la page PDF, pas d'`ACCESS_ADVERT`) ; persistance **par parcelle et par utilisateur** (defaut
affiche) **ET** au niveau estimation quand la fiche est ouverte depuis un lead.

**Etat de depart mesure** (le module n'avait jamais pu marcher) : nav commentee, init gatee par une classe inexistante, seule
source `POST /city/dvf/bbox` dont la sortie n'a ni `surfaceHabitable` ni `adresse` (prix 0, surface 0 a l'ecran), aucune
source d'annonces (onglet « En vente » vide par construction), selection elaguee a chaque `moveend`, aucune persistance,
`cleanup` qui ne detruisait pas la carte (instances empilees a chaque reouverture AJAX), ids morts.

**Livre**
- **Resolution UNIQUE** : trait de site `App\Http\Traits\ResolvesAvisComparables` (`resolveAvisComparables`,
  `syncEstimationComparables`, `resolveComparablesLeadEstimation`, normaliseurs) consomme par `TabEstimController::load`
  (onglet) **et** `ActionController::parcel_avis_valeur` (PDF). Ordre : lignes `estimation_comparables` de l'estimation du
  lead (contexte `link_type=lead`) > lignes `parcel_avis_comparable_selections` (user + multiparcel + `source_type`) >
  automatique (`getSimilarityDvfIds` / `getSimilarityPropertieIds`, **limite 10**, `bati_type` brut comme le PDF l'a
  toujours fait). L'onglet et le PDF ne peuvent plus diverger.
- **Tables** (core-city-user, 2 migrations de structure) : `parcel_avis_comparable_selections` et `estimation_comparables`
  (`kind` = `dvf` | `advert`, `ref_id` = `fr_dvfs.id` | `properties.id`, `position`) + modeles
  `Parcel_avis_comparable_selection` (`MAX_PER_KIND = 10`) et `Estimation_comparable`, relation `Estimation::comparables()`.
- **Endpoints** `Parcel/TabComparablesController` (groupe `tab`, `planaccess:ACCESS_ESTIMATE`, meme garde parcelle que les
  autres onglets) : `POST /parcel/tab/estimation/comparables/dvf` et `/adverts` (bounds + filtres, plafond 200 +
  `truncated`, etendue > 0,2° = 422), `/selected` (detail des ids retenus, meme hors zone), `/state`, `PUT` (max 10 par
  famille = 422 `too_many`, id hors rayon 0,02° = 422 `out_of_range`, lead inaccessible = 404, `selection_provided=1`),
  `DELETE` = retour a l'automatique (niveau parcelle et, en contexte lead, niveau estimation).
- **Normaliseurs = formules du PDF** : prix DVF `valeurfonc / nblot` si `nblot > 1`, surface = somme des parcelles par
  `getSurfaceHabitable($d->bati_type, $d)`, adresse = premiere de `getAddresses()` ; annonce = `price.latest.value`,
  surface terrain ou `habitation.surface.total`, titre `getLibelleBatiType(..., 'titleLong')`. Ce que l'onglet affiche est
  ce que le PDF imprime.
- **Ecriture** : `PUT` ecrit le niveau parcelle et, si `link_type=lead` avec une estimation existante pour ce lead + type,
  aussi `estimation_comparables` ; a la generation, `syncEstimationComparables` fige dans l'estimation ce qui a ete imprime.
- **Vue** `card_estimate_tab_similar_properties` reecrite (racine `#similarPropertiesTab` avec `data-dvf-ids`,
  `data-advert-ids`, `data-origin`, `data-source-type`, `data-link-type/id`, `data-max-per-kind`, `data-url`), badge
  « Selection automatique / personnalisee », compteurs `x/10`, bouton « Revenir a la selection automatique » (Swal), mode
  location : bascule « Vendus » masquee + note « le PDF en location ne comporte pas de page biens vendus ». Hauteur de carte
  explicite (`clamp(520px, 100vh - 250px, 880px)`). `#estimateCityMap data-dvf` suit desormais la selection resolue
  (10 cadastres, plus 8).
- **JS** `parcel-tabs/tab-similar-properties.js` reecrit (etat `S`, `initSimilarPropertiesTab` idempotent par `data-p`,
  `cleanupSimilarPropertiesTab` = `map.remove()` + cluster + timers + `$(document).off('.similarProps')`, appele par
  `tab-init.js` (`handleTabChange` hors `#tab-estim`, `closeParcel`) ; autosave PUT debounce 600 ms ; selection conservee
  hors bbox (section « Hors de la zone visible ») ; tout decoche = le serveur rend l'auto et l'UI le reapplique avec toast ;
  rechargement des donnees au changement de type de bien (`notifyBatiTypeChanged`, watcher `.batiTypeListItem`).
- **PDF (core-city)** : `page_comparer_bien_vendu` / `page_comparer_bien_en_vente` honorent l'ordre transmis quand la liste
  est une **liste** (`array_is_list`) ; en automatique le tableau associatif historique est inchange, **HTML identique
  au md5** (harness A/B). Signature du job `PdfParcelsAvis` inchangee.

**🟥 Defaut trouve UNIQUEMENT par la recette navigateur** : les boutons « + / ✓ » des listes (`.btn-property-select`)
n'avaient aucun handler - seuls ceux des popups de la carte (`.btn-select-property`) en avaient. Les 39 assertions HTTP et
le harness PHP ne pouvaient pas le voir. Corrige (un seul handler delegue sur les deux classes, `updateSelectionButtons`
couvre les deux).

**Verifie** : harness PHP `.insights/avis-comparables-2026-09-18.php` **32/32** (auto identique aux appels package,
ordre, limite 10, vente/location isolees, surcharge estimation + sync + cascade, reset, normaliseurs = formules PDF) ; E2E
HTTP `.insights/avis-comparables-e2e-2026-09-18.sh` **39/39** (session reelle, 422/404, persistance, lead 1473, **PDF produit
par les workers** et ordre relu par `pdftotext`) ; A/B PDF `.insights/avis-comparables-pdf-ab-2026-09-18.php` + `.sh`
(legacy v1.0.333 restaure dans le vendor : md5 identique en auto) ; **E2E navigateur** `.insights/avis-comparables-cdp-e2e-2026-09-18.py`
**28/28** (Chrome headless + session reelle, carte MapLibre rendue, 200 biens de zone, preselection = `data-dvf-ids`,
echange d'un DVF → PUT 200, 11e refuse, retrait d'une annonce, dezoom + drag → zone rechargee et selection conservee,
fermeture/reouverture → `origin=parcel` et memes cles, retour a l'auto → DELETE 200, 0 erreur console) ; PHPUnit
**157 / 3 echecs preexistants** ; 0 ligne de log imputable.

⚠ **Recette navigateur : l'onglet Claude in Chrome etait en arriere-plan** (`visibilityState = hidden`) - MapLibre ne charge
pas son style sans `requestAnimationFrame`, la carte reste sur son loader et `loadArea` ne part jamais (et `addPolygon` de
l'onglet Estimation logue « Style is not done loading », artefact connu). Recette faite en **Chrome headless jetable**
(`--headless=new --use-angle=swiftshader --enable-unsafe-swiftshader --remote-allow-origins='*'
--host-resolver-rules="MAP <hote ngrok> 127.0.0.1" --ignore-certificate-errors`) pilote en CDP avec une session reelle par
`autologin` (token 64 hex). Sans SwiftShader, `showMapl` echoue sur « Failed to initialize WebGL » (le `try/catch` de
l'onglet le rend proprement par un toast). ⚠ Le deep-link `/cadastre/{id}` n'ouvre pas la fiche en headless (Google Maps
ne bootstrappe pas) : `window.showParcelCard(id, true)` en repli.

⚠ Les cles des items sont `dvf-<id>` / `advert-<id>` (tiret). ⚠ Il n'existe **aucun etat « vide »** : tout decoche = auto.
⚠ Une selection faite depuis un navigateur pendant la session (parcelle 66227000AK0315, user 2, 08:14-08:15) est la
selection de l'utilisateur, **conservee**.

⚠ **Deploiement** : tag core-city-user + tag core-city, `composer update` sur ce site **ET administration** (workers PDF),
`php artisan migrate` (depuis administration), `queue:restart`, `npm run prod`, `view:clear`, `route:clear` ;
captimmo / rootsud exclus. En dev, les deux vendors (1.0.37 / 1.0.333, alignes sur HEAD) ont recu les fichiers par
`docker exec cp`.

**Complement du meme jour : defilement de l'onglet (retour utilisateur).** Deux defauts signales apres livraison.
- **La barre `.sp-selection-bar` et l'aide `.sp-help` faisaient deborder `#parcelCardContent`** (mesure a 1700x1050 :
  39 px de trop), d'ou la barre de defilement de la fiche, et la molette sur la carte faisait defiler la fiche en plus de
  zoomer. La barre est **retiree** : l'origine de la selection, les compteurs `x/10` et « Revenir a la selection
  automatique » vivent dans le bloc `.sp-selection-meta` en tete du panneau « Ma selection » (memes ids, le JS est
  inchange) ; l'indicateur d'enregistrement `#comparablesSaveState` devient une pastille sur le bouton etoile, visible
  panneau ferme ; `#btn-reset-selection` porte le titre « Revenir a la selection automatique » (il ne vidait rien).
  L'aide est conservee. La hauteur de la carte est desormais **calculee** (`fitMapHeight` dans
  `tab-similar-properties.js`) pour que l'onglet remplisse exactement la fiche : variable CSS `--sp-map-height` (le
  `clamp` du SCSS ne sert qu'avant le calcul), plancher 380 px, recalcul au redimensionnement, a la sortie du plein
  ecran et par un `ResizeObserver` sur la fiche et l'aide (bascule plein ecran / demi-ecran, aide qui passe sur deux
  lignes). ⚠ **Deux anciens calculs posaient une hauteur approximative en style inline** (hauteur de la fiche - sous-nav
  - 32 px) qui ecrasait la variable : `tab-init.js` (chargement de l'onglet Estimation) et `responsive.js`
  (redimensionnement) - **retires**, ne pas les remettre.
- **Dans les panneaux (`#panel-list-dropdown`, filtres, selection), la molette restait bloquee en haut** tant qu'on ne
  tirait pas la barre a la main. 🟥 **C'est un defaut de PerfectScrollbar 1.5** (`shouldBeConsumedByChild`, instance de
  `#parcelCardContent`) : `deltaY` est inverse (`-e.deltaY`), donc au bord d'un enfant defilant (haut en descendant, bas
  en remontant) le test dit « non consomme », la PS de la fiche s'empare de l'evenement, appelle `preventDefault` et
  fait defiler la fiche a la place. Le gestionnaire `wheel` qui existait etait **delegue sur `document`** : il
  s'executait APRES la PS (plus bas dans l'arbre) et ne pouvait rien. Correctif : classe **`ps__child--consume`** sur
  `#mapFullscreenContainer` (mecanisme prevu par PS : tout ce qui est dessous est laisse au navigateur) +
  `overscroll-behavior: contain` sur les listes ; les panneaux passent en **colonne flex** (la liste prend le reste,
  plus de hauteur de liste calculee en JS). ⚠ **Le meme defaut peut toucher tout autre bloc defilant imbrique dans
  `#parcelCardContent`** (autres onglets de la fiche) : non traite, meme remede au cas par cas.
- Verifie en Chrome headless (session reelle) par `.insights/similar-props-scroll-probe-2026-09-18.py` (avant : 39 px de
  debordement, liste a 0 apres 3 crans, fiche defilee ; apres : 0 px a 1700x1050 et 1440x820, liste 0 → 360 et retour
  depuis le bas, fiche immobile) et `.insights/similar-props-panel-check-2026-09-18.py` **16/16** (bloc d'origine,
  liste qui remplit le panneau, pastille pendant le PUT, retour a l'automatique depuis le panneau, plein ecran = 100 %
  de l'ecran puis 0 px de debordement a la sortie, fiche retrecie a 560 px sans debordement, 0 erreur console) ; recette
  de l'onglet rejouee **28/28** ; selection rendue a l'automatique (0 ligne), 0 erreur de journal.
  **Deploiement** : `npm run prod` + `view:clear`, 0 migration, 0 route, 0 fichier de package.
- **Cartes `.property-item` compactees (meme jour, 2e retour)** : 238 px (vendus), 332 px (annonces) et 255 px (selection)
  → **92 px** partout, soit ~7 cartes visibles au lieu de 2 a 3. Vignette de 56 px a gauche (photo de l'annonce, sinon
  icone du type ; une photo introuvable bascule sur l'icone via `onerror` → classe `photo-error`), 4 lignes courtes
  (titre, adresse, `surface · €/m² · date`, prix + pastille « Hors zone visible »), bouton de selection a droite. Le
  badge texte « VENDU / EN VENTE » est remplace par la couleur de l'icone (infobulle) : le type est deja donne par
  l'onglet de la liste et par les intitules de groupe de la selection. Memes classes qu'avant (`.property-item`,
  `.type-*`, `.selected`, `.btn-property-select`, `.property-price`, `.sp-out-of-area`). 🟥 **Une regle GLOBALE
  `.property-card-content` d'`advert.scss`** (padding 1rem, titre en `-webkit-box` + `line-clamp: 2`) fuit dans ces
  cartes : neutralisee dans le bloc de l'onglet (`padding: 0`, titre en `display: block`) - c'est elle qui laissait
  32 px de trop au premier essai. Mesure et captures : `.insights/similar-props-items-measure-2026-09-18.py` ;
  recette rejouee **28/28** sur la parcelle voisine 66136000EO0305 (la parcelle de test porte la selection de
  l'utilisateur, ne pas la reinitialiser). **Deploiement** : `npm run prod`.
- **Carte : sélection toujours en jaune + une famille de points à la fois (même jour, 3e retour)**.
  🟥 **Les DVF sélectionnés apparaissaient verts** : plusieurs mutations DVF d'une même parcelle ont les MÊMES
  coordonnées (mesuré : 6 des 10 DVF de la sélection automatique de 66136000EO0306 partagent leur parcelle avec 1 à 3
  autres mutations), et le point non sélectionné, dessiné après, recouvrait le jaune ; les annonces, elles, ont chacune
  leur position. Correctif : `circle-sort-key` sur `unclustered-point` (sélectionné = 1) + sélectionnés ajoutés en
  dernier dans la source. **Trop de points** : la légende devient un sélecteur « Afficher sur la carte » (Biens vendus /
  En vente / Ma sélection, une famille à la fois, `S.mapMode`, défaut = onglet de la liste) ; Vendus / En vente suivent
  aussi l'onglet de la liste (et la bascule de la liste fait suivre la carte), sans ouvrir le panneau ; « Ma sélection »
  recentre la carte sur les biens retenus (marge à gauche si un panneau est ouvert) et passe les regroupements en jaune.
  Vérifié par `.insights/similar-props-map-mode-2026-09-18.py` **12/12** (instance MapLibre récupérée en interceptant
  `maplibregl.Map.prototype.addSource`, sans code de test dans le produit ; couleur du pixel au point superposé :
  jaune, et vert en contre-épreuve avec l'ancien ordre) + recette **28/28**. **Déploiement** : `npm run prod` + `view:clear`.
  **Puis (4e retour)** : ouvrir le panneau « Ma sélection » (bouton étoile) passe la carte en mode sélection (DVF et
  annonces retenus seulement, carte recentrée) ; le refermer (croix, étoile, ou ouverture d'un autre panneau) rend la
  carte à l'onglet de la liste, sauf si « Ma sélection » avait été choisi dans la légende (choix explicite conservé,
  drapeau `S.selectionByPanel`). `syncMapModeWithPanels()` est appelé par `openPanel`, `togglePanel` et les croix.
  Vérifié par `.insights/similar-props-selection-panel-map-2026-09-18.py` **11/11** + recette 28/28 + carte 12/12.
  **Puis (5e retour) : pastille « Hors zone visible » fausse.** Elle etait calculee par `!S.area.has(key)` = « absent des
  resultats de la zone », or ces resultats sont PLAFONNES (200) et FILTRES (type de bien, prix...) : un bien bien visible a
  l'ecran etait annonce hors zone. Elle est desormais jugee sur la position (`isInView` = `map.getBounds().contains`) et
  mise a jour a chaque `moveend` sans re-rendre la liste (`updateOutOfViewBadges`). Verifie par
  `.insights/similar-props-out-of-view-2026-09-18.py` **6/6** (0 incoherence badge/position a 3 niveaux de zoom ;
  contre-epreuve : filtre de prix actif, l'ancien critere marquait 10 biens visibles, le nouveau aucun) + recette 28/28.
  ⚠ **Constat NON traite** : la selection AUTOMATIQUE peut compter 9 ventes au lieu de 10 - `getSimilarityDvfIds` rend
  `[cadastre_id => dvf_id]` et une vente portant sur 2 parcelles y figure deux fois (vente 1569932, parcelles EL0575 et
  EL0576, autour de 66136000EO0304 / EO0307). L'onglet et le PDF sont coherents entre eux (9 lignes), mais en dessous du
  quota. Corriger = dedoublonner et completer (demander plus de candidats), ce qui change le PDF en automatique : decision
  a prendre. La recette CDP (qui attend 10) echoue donc sur ces deux parcelles ; la jouer sur EO0306.
  ⚠ La recette `avis-comparables-cdp-e2e-2026-09-18.py` **ferme le navigateur** (`Browser.close`) : relancer Chrome avant
  toute autre sonde. ⚠ Parcelles EO0305 et EO0307 : selections de l'utilisateur en cours, ne pas les reinitialiser.
  **Puis (6e retour) : legende trop encombrante sur mobile.** Sous 768 px elle tient sur une ligne (titre masque,
  libelles courts `sp-label-short` « Vendus / En vente / Selection », badge des filtres et barre de chargement reduits a
  leur icone, `max-width` laissant la place aux boutons de zoom) : 294 x 46 px a 390 px de large, 6,5 % de la carte,
  aucun chevauchement. Bureau inchange (titre + 3 lignes). Verifie par `.insights/similar-props-legend-mobile-2026-09-18.py`.
  **Puis (7e retour) : le marqueur de la parcelle (`fa-home`) passait devant les panneaux.** `.marker-current-parcel`
  porte `z-index: 1000 !important` (les popups aussi) et la carte ne formait pas de contexte d'empilement : ce z-index
  etait compare a celui des panneaux (100). `#similarPropertiesMap` est desormais isole (`position: relative; z-index: 0;
  isolation: isolate`) : marqueur et popups restent au-dessus de la carte, sous les panneaux, les statistiques, les
  controles et la legende. Verifie par `.insights/similar-props-marker-stacking-2026-09-18.py` **7/7** (contre-epreuve :
  ancien empilement = marqueur devant le panneau) + recette 28/28.
  **Puis (8e retour) : texte d'aide trop haut sur mobile.** `.sp-help` porte un bouton `.sp-help-toggle` (« Biens repris
  dans l'avis de valeur PDF », chevron) visible sous 768 px seulement : l'aide tient une ligne (16 px) et deplie le texte
  complet au toucher (`is-open`, `aria-expanded`) ; la carte se reduit d'autant (ResizeObserver existant), jamais de
  debordement. Bureau inchange (texte complet, pas de bouton). Verifie par `.insights/similar-props-help-mobile-2026-09-18.py`
  (mobile 6/6, bureau 2/2) + recette 28/28.

  **Puis (9e retour) : les selections ne doivent pas stocker l'id des DVF.** `core_city` est vide puis reconstruit (taches
  relancees, base importee en prod sous `core_city_new` puis renommee) : un `fr_dvfs.id` stocke aurait designe une autre vente.
  Les deux tables stockent desormais `ref_key` = `idmutation` (DVF, index UNIQUE ajoute sur `fr_dvfs`) ou `flxId` (annonce),
  traduite par `ComparableRefKeys` de core-city-user ; le site, l'API de l'onglet, le JS et le PDF manipulent toujours les ids
  courants (0 fichier du site modifie hors `.insights`). Conversion des lignes de dev :
  `.insights/convert-comparables-ref-key-2026-09-18.php` (115 lignes, 0 introuvable). Harness
  `.insights/comparables-ref-key-2026-09-18.php` **13/13** (reconstruction simulee : la vente est suivie sous son nouvel id).
  E2E HTTP rejoue **39/39** (PDF des workers, ordre relu) et recette navigateur **28/28**. 🟥 **L'E2E a ecrase une estimation
  reelle** : la parcelle EO0306 portait l'estimation 424 creee depuis le navigateur de l'utilisateur, le script a supprime ses
  comparables au « depart propre » puis regenere son PDF avec sa propre selection. Restauree (7 comparables d'origine, PDF
  regenere avec la selection de l'utilisateur, parcelle rendue sans selection). L'E2E prend desormais la parcelle en parametre
  (`CAD=`, defaut EO0303) et **refuse de tourner** si l'utilisateur y a une estimation ou une selection (plus aucun nettoyage
  aveugle). ⚠ Reserve : `idmutation` = cle primaire du DVF+, `idmutinvar` = identifiant invariant (cf. `core-city-user/CLAUDE.md`).

  **Puis (10e retour) : bloc d'etat de « Ma selection » refait, poubelle, et biens hors perimetre grises.**
  - `.sp-selection-meta` (`#comparablesMeta`, `data-state` = `auto` | `custom` | `empty`) : icone d'etat, titre
    (`#comparablesOriginBadge`, texte seul : la recette le compare mot pour mot), explication (`#comparablesOriginHint`,
    textes dans `data-hints`), lien « Revenir / Reprendre la selection automatique » (`#btnComparablesResetAuto`, masque en
    automatique), **poubelle `#btnComparablesClear`** (masquee quand la selection est vide), et une **jauge par famille**
    (`.sp-quota[data-kind]`, remplissage = n / 10, coche verte a 10 ; une seule jauge en location). Mis a jour par
    `updateSelectionMeta()`, appele par `updateCounters()` et `setOriginBadge()`.
  - **Etat « vide »** : une selection sans bien prime sur son origine a l'affichage. Le serveur n'enregistre alors aucune
    ligne, donc l'avis de valeur reprend l'automatique - l'explication le dit. ⚠ **Changement de comportement voulu** :
    retirer le dernier bien ne recharge plus la selection automatique dans la liste (remplissage surprise) ; la liste reste
    vide avec un etat « Composez votre selection » + « Parcourir les biens » (`#btnSelectionBrowse`). Reprendre
    l'automatique depuis l'etat vide se fait sans confirmation (rien a perdre).
  - **Poubelle** : confirmation (nombre de biens + consequence sur l'avis), puis `clearSelection()` = selection videe a
    l'ecran + `PUT` vide immediat (`saveSelection({ cleared: true })`). Un ajout ensuite repart d'une selection
    personnalisee ; l'etat « personnalise » est annonce des l'ajout, sans attendre la reponse (`afterSelectionChanged`).
  - 🟥 **Defaut du lot precedent trouve par ce test** : la liste et les popups proposaient les 200 biens de la zone visible
    alors que l'enregistrement n'accepte que ceux a moins de `COMPARABLES_MAX_RADIUS` (0,02 degre, carre autour du point de
    la parcelle) : le `PUT` repondait 422 `out_of_range` et le bien restait coche a l'ecran (116 biens sur 200 concernes
    autour de EO0303). Desormais `isInRange()` applique la meme regle cote client (rayon transmis par `TabEstimController`
    en `comparablesState.max_radius` → `data-max-radius`) : bien grise, pastille « Trop eloigne », bouton inactif, toast
    explicite au clic ; et un 422 `out_of_range` retire les biens refuses (`dropRefusedItems`) puis reenregistre le reste.
  - Verifie en Chrome headless (session reelle, parcelle EO0303 sans donnee utilisateur) : `meta_flow.py` **21/21**
    (poubelle, etat vide, base a 0 ligne, clic hors perimetre sans effet, ajout → personnalise + 1 ligne, retrait du
    dernier bien → reste vide, reprise sans confirmation → 19 biens, base rendue a l'identique), refus serveur simule par
    interception CDP **5/5** (bien retire, reste reenregistre), mobile 390 px sans debordement, recette de l'onglet
    **28/28**, 0 erreur console, 0 ligne de journal. ⚠ **Piege de harness** : `multiparcel_id` = code ASCII de chaque
    caractere sur **3 chiffres**, zeros de tete retires ; un encodage sur 2 chiffres ne matche rien et fait passer a tort
    toute assertion « 0 ligne ». **Deploiement** : `npm run prod` + `view:clear` (0 migration, 0 route, 0 package).

  **Puis (11e retour) : perimetre porte a 20 km, moyennes du PDF corrigees, carte du PDF lisible avec des biens eloignes.**
  - **Perimetre** : `ResolvesAvisComparables::COMPARABLES_MAX_DISTANCE_KM = 20` (distance a vol d'oiseau, haversine
    `comparablesDistanceKm`) remplace `COMPARABLES_MAX_RADIUS` (carre de 0,02 degre, ~2 km). Cause des biens souvent
    grises : la carte de l'onglet ne descend pas sous le **zoom 13, qui montre ~5 km autour du centre**. Serveur
    (`comparablesOutOfRange`, DVF accepte si l'une de ses parcelles est a moins de 20 km, annonce sur son point, message
    « a plus de 20 km ») et client (`isInRange` / `distanceKm`, meme formule, `data-max-distance-km`) alignes.
    Cercle pointille rouge de 20 km + etiquette « Limite de l'avis de valeur : 20 km » (marqueur DOM au nord du cercle,
    `addRangeLimit`). La selection AUTOMATIQUE garde son propre rayon (0,02 degre), inchangee.
  - **Point d'une vente = parcelle la plus proche du bien** (`normalizeDvfForUi`, et `PdfParcelsAvis` cote PDF) : une
    mutation peut porter sur des parcelles eloignees (mesure : 21 ventes sur 5 845 d'une zone de 2,6 x 1,4 km ont des
    parcelles a plus de 2 km l'une de l'autre, jusqu'a 81 km). Avant, la premiere parcelle servait de point : la liste
    affichait des biens a 18 km au premier chargement (grises) et le PDF aurait etire sa carte sur 81 km.
  - 🟥 **« Moyenne de la selection » du PDF fausse** (signale : 380 901 EUR contre 336 640 EUR dans l'onglet) : les pages
    `page_comparer_bien_vendu` / `_en_vente` faisaient `moyenne = (moyenne + prix) / 2` a chaque ligne, ce qui surpondere
    les derniers biens. Moyenne arithmetique desormais, sur les biens chiffres (meme regle que `avg()` de l'onglet),
    libellee « Prix moyen : X EUR net vendeur » (ou « Loyer moyen : X EUR/mois »). Detail : `core-city/CLAUDE.md`.
  - Au passage (core-city) : numeros de la carte du PDF alignes sur la liste, pastilles qui se chevauchent ecartees en
    couronne avec un trait vers leur position reelle, et selection automatique a 10 ventes DISTINCTES (une vente sur
    plusieurs parcelles prenait plusieurs places : 16 parcelles sur 36 mesurees imprimaient moins de 10 ventes).
  - Verifie : `.insights/avis-comparables-zone-moyenne-2026-09-18.sh` **24/24** (refus a 20,14 / 29,3 / 23,3 km, accepte a
    19,85 et 15,8 km, les 10 ventes de la capture utilisateur rejouees sur EO0303 → PDF « Prix moyen : 336 640 EUR » et
    2 852 EUR/m2, ordre respecte, base rendue a l'identique), recette navigateur de la zone 13/13 (0 bien grise a zoom 15
    et 13, bascule exacte a 20 km, bien a 17 km ajoute), recette de l'onglet **28/28** (passee par defaut sur EO0303 :
    EO0306 porte l'estimation 424 de l'utilisateur), recette HTTP 31/31 (volet lead desormais opt-in `LEAD_TESTS=1`),
    A/B de la selection auto `.insights/auto-dvf-dedup-ab-2026-09-18.php`, PHPUnit 157 / 3 preexistants, 0 erreur console,
    0 log. **Deploiement** : tag core-city + `composer update` (site ET administration) + `queue:restart` +
    `npm run prod` + `view:clear` (0 migration, 0 route).

  **Puis (12e retour) : le panneau de stats devient « Dans l'avis de valeur », reflet des pages « Comparer » du PDF.**
  - `#stats-dropdown.sp-stats` : estimation au m2 lue en direct (`.cardEstimatePricePpmValue`, la valeur que le PDF
    compare), puis une carte par page du PDF (« Biens vendus » en vente seulement, « Biens en vente / en location ») avec
    l'encadre de synthese du PDF a l'identique : EUR/m2 moyen, « Moyenne de la selection (N biens) », « Prix moyen : X EUR
    net vendeur / honoraires inclus » ou « Loyer moyen : X EUR/mois », et l'ecart « Votre estimation vs marche » (formule,
    arrondi et seuils de couleur de `page_comparer_bien_*`, `marketDiff()`). Avertit quand une page sera imprimee sans
    comparable (famille vide dans une selection personnalisee) et quand des biens sortent d'une moyenne (sans surface,
    sans prix). Le graphique ApexCharts (k EUR et EUR/m2 sur le meme axe) est remplace par une reglette des EUR/m2 : un
    point par bien (clic = carte centree + bulle), la moyenne de chaque page, l'estimation en pointille.
  - Verifie en Chrome headless : `stats_states` **20/20** (moyennes et ecarts recalcules independamment et egaux, couleurs,
    selection vide, page vide, singulier, location par injection de `sourceType=1`), bien sans surface **3/3**, mobile
    390 px sans debordement, recette de l'onglet **28/28**, zone **14/14**, 0 erreur console, 0 log. Le PDF imprime l'ecart
    au format francais (« -34,2 % », etait « -34.2% ») pour correspondre. ⚠ **Piege de recette** : ouvrir l'onglet
    Estimation ECRIT les reglages `estimation_*` de `parcel_user_details` (`POST /estimate` a chaque calcul) ; les
    recettes `.insights/avis-comparables-*` refusent desormais une parcelle qui en porte et les retirent a la fin.
    **Deploiement** : `npm run prod` + `view:clear` (+ le tag core-city du lot precedent pour le format de l'ecart).

  **Puis (13e retour) : 3 defauts corriges - panneau pleine hauteur en mobile, prix au m2 suivi en direct, carte blanche au retour sur l'onglet Estimation.**
  - 🟥 **Carte blanche** : `handleTabChange` (tab-init.js) appelait `cleanupSimilarPropertiesTab()` en quittant l'onglet
    Estimation, or son contenu reste en cache (`data-loaded`) : au retour « Biens similaires » etait toujours ouvert, aucun
    `shown.bs.tab` du sous-onglet n'etait emis et la carte detruite n'etait jamais recreee. Desormais
    `suspendSimilarPropertiesTab()` au depart (carte conservee ; nettoyage complet seulement si le contenu a ete remplace)
    et `resumeSimilarPropertiesTab()` dans le rappel de `#tab-estim`. 🟥 Au passage : ce nettoyage ANNULAIT
    l'enregistrement differe (600 ms), une modification faite juste avant de changer d'onglet ou de fermer la fiche etait
    perdue ; `flushPendingSave()` l'envoie. `syncRootDataset()` garde `data-dvf-ids` / `data-advert-ids` / `data-origin`
    a jour : une reinitialisation repart de la selection enregistree, pas de celle du premier rendu.
  - **Prix au m2 de l'estimation** : `MutationObserver` sur `#tab-estim` entier (le recalcul remplace les elements
    `.cardEstimatePricePpmValue`, un observateur pose sur eux devenait sourd), mutations de l'onglet « Biens similaires »
    ignorees (pas de boucle : `updateStats()` ecrit dans cet onglet), recalcul seulement si la valeur change ;
    `updateStats()` aussi au re-affichage du sous-onglet ; observateur deconnecte au nettoyage.
  - 🟥 **Hauteur du panneau** : `adjustDropdownHeights` mesurait le bas de `.action-stats-panel`, qui CONTIENT le
    panneau : ouvert, il reduisait sa propre place (bloque a 200 px, 130 px de contenu meme sur ordinateur). Haut calcule
    depuis `.action-pills-compact` + ecart de colonne ; en mobile / tablette (<= 768 px) hauteur fixe jusqu'au meme bas que
    les panneaux de gauche (haut reel mesure par `measureLeftPanelTop()`, leur `top: calc(100% + gap)` n'etant pas lisible
    masque) ; sur ordinateur, hauteur du contenu plafonnee au bas de la carte (620 px disponibles au lieu de 130).
  - Verifie en Chrome headless (`fixes_check`) **17/17** : carte conservee et dessinee au retour (meme instance, 0 carte
    empilee), retrait puis changement d'onglet immediat → PUT 200 et selection relue, estimation 1 967 → 2 600 EUR/m2 via
    `#estimate-ppm` → panneau a jour avec l'ecart recalcule, changement pendant l'affichage suivi en direct, mobile 390 px
    bas identique aux panneaux (835 / 835) ; non-regression `stats_states` 20/20, onglet 28/28, zone 14/14, 0 erreur
    console, 0 log, base rendue a l'identique. **Deploiement** : `npm run prod` (0 migration, 0 route, 0 package).

  **Puis (14e retour) : « Biens similaires » place en 3e sous-onglet, apres « Critères » et avant « Points clés ».**
  - Ordre : Prix & Analyse, Critères, **Biens similaires**, Points clés, Photos, Textes IA, Annexes (nav ET panneaux dans
    `card_estimate_modern.blade.php`). Raisons : (1) ordre de travail - on decrit le bien (type, surface) avant de choisir
    ses comparables, dont les filtres suivent le type de bien de l'estimation ; (2) ordre du PDF - presentation du bien,
    pages « Comparer les biens vendus / en vente », statistiques, points cles, evaluation ; (3) l'onglet reste dans le bloc
    « valeur » (Prix, Critères, Points clés), les trois derniers etant le contenu du document (photos, textes, annexes).
    Aucun code ne depend de la position (recherche sur les index / `:last` : rien). Verifie : ordre et ouverture des 7
    sous-onglets a 1700 et 390 px (9/9), meme carte au retour, recette de l'onglet 28/28, 0 erreur. **Deploiement** :
    `view:clear`.

### Score de localisation et rubrique « Annonce localisée » (2026-09-30)
- `App\Services\AdvertLocate\LocateReplay` et `AdvertFilterTuner` sont désormais des classes filles vides de
  core-advert (`Rootsud\Advert\Services\Locate\`) : modifier le code DANS core-advert.
- Menu Opportunités : case `#opp-type-annonce` (compteur `#oppCountAnnonce`, `OpportunityController::opportunityTypeCounts`
  compte aussi `annonce_count` ; `terrain_count` ne compte plus que le PLU), pastilles `.opp-chip-advert-score`
  (choix unique, entrée `ModuleFilterSync` `foncier_opportunity_advert_score` ↔ `opp_advert_score`), section
  `.opp-filter-section-annonce`, badge `filter_result_item_opportunity_badge` « Annonce N % » lien `/annonce/{id}`.
- Carte ANNONCE (type « annonce » retenu) : `opportunity/_advert_opportunity_item.blade.php`, données de `OpportunityController::advertOpportunityCards` (2 requêtes / page) ; mise en page : 3 lignes (titre + prix, commune + terrain / pièces / DPE, parcelle + date) et anneau de score dans une colonne à droite, en surimpression sur la photo en vignettes ; container query sur `.parcel-content` qui masque terrain (< 230 px) puis pièces (< 180 px), 0 débordement mesuré de 170 à 781 px ; clic `.opp-advert-open` → `openAdvertFromOpportunity` (crumb « Opportunités », contexte `sessionStorage.opportunity_return` avec la zone de recherche, restaurée au retour par `restoreOpportunitySearchZone`).
- Filtres de l'annonce (type « annonce ») : partial `opportunity/_advert_criteria_filters` (panneau + tiroir mobile, `$mobile`), pastilles `.opp-chip-advert-offer` (choix unique) et `.opp-chip-advert-property`, champs `.opp-advert-range` (`oppAdvertPriceMin/Max`, `oppAdvertSurfaceMin/Max`, suffixe `Mobile`) ; paramètres `foncier_opportunity_advert_*` envoyés par `getOpportunityFilters` si le type annonce est retenu, synchro légende par 6 entrées `ModuleFilterSync` (`opp_advert_*`) ; `advertOpportunityCards` applique les mêmes critères. Valeurs = menu Annonces.
- Annonce déjà localisée (carte ANNONCE) : `OpportunityController::advertLocationsForCards` lit `advert_locations` de l'utilisateur (clé du bien `properties.flxId`, et celle de la diffusion pour les anciennes lignes), 1 requête + 1 pour le lead (`Lead::sharedFor` sur la parcelle liée : `advert_locations` n'a pas de `lead_id`) ; la carte porte `data-located`, un liseré vert, la pastille « Localisée · parcelle » (infobulle : date, toutes les parcelles, top 1 du score s'il diffère) à la place de la parcelle du top 1, et une pastille « Lead » (`.opp-advert-lead`, ouvre la fiche lead par `spaNavigate`). Retour depuis la fiche annonce : `showLeftMenu` recharge désormais la liste dès que `#opportunityList` est absent (même garde que le menu Acquisition), sinon l'état pouvait rester figé.
- Panneau de filtres (bureau `opportunity_toolbar` et tiroir mobile `opportunity_filters`) groupé par type : en-têtes `opportunity/_filter_group_head` (`$group` = `common` | `annonce` | `dpe`, titre + « s'applique à / n'affecte pas ») et filet de la couleur du type sur les sections du groupe. Ordre : Types, Toutes les opportunités (surface parcelle), Annonces localisées, DPE. L'en-tête porte la classe `opp-filter-section-*` de son groupe et se masque avec lui (`updateFilterSectionsVisibility`).
- **Situer un appartement dans son immeuble (2026-09-30)** : service `App\Services\AdvertLocate\ApartmentLocator` - `label()` (étage, porte / n° de lot, bâtiment, typologie, surface d'un DPE ; ⚠ `numero_etage_appartement` vaut « 0 » par défaut dans la plupart des DPE : 0 = RDC seulement si `position_logement_dans_immeuble` le dit ; références de dossier du diagnostiqueur et « Lot NC » écartées), `forDpeNumbers()`, `profileForFlx()`, `forAdvert()` (DPE d'appartement des parcelles qui correspondent à l'annonce : empreinte = même date + même consommation ou émissions ; date ; surface à 5 % + même étiquette, seulement si non ambiguë et confirmée par l'étage annoncé). Affiché : cartes d'opportunité DPE (`OpportunityController::dpeApartmentLabels`, pastille `.opp-apt-chip` dans `filter_result_item_opportunity`), carte annonce (étage annoncé dans les caractéristiques + logement probable, `advertApartment`), cartes de parcelle du top 10 et des résultats de la fiche annonce (`POST /acquisition/advert/apartment-hints` → `injectApartmentHints`, ligne `.parcel-apt-row`, rien hors appartement). Mesure : 2 annonces sur 5 d'appartement désignent le logement par l'empreinte (5e étage 68 m², RDC 44 m²). Au passage : le nombre de pièces de la carte annonce lisait `feature->roomCount` (inexistant), corrigé en `habitation->roomCount`. Appartement non situé par les DPE : pastille grise `.opp-apt-chip.is-unknown` (`ApartmentLocator::fallback`, « Annonce : 5e ét./5 » ou « Logement non situé », raison au survol) sur la carte annonce des Opportunités et sur les cartes du top 10 (pas sur #resultList, pour ne pas charger 42 cartes). Étage : `ApartmentLocator::display()` retient l'étage de l'ANNONCE quand elle le donne (le champ libre du DPE peut être faux : annonce 214768628, DPE identique par date + émissions + surface + étiquette, « Rez-de-chaussée » dans le DPE pour un « dernier étage » annoncé) et signale la contradiction au survol ; porte et bâtiment viennent du DPE (`doorWithoutFloor` : « Etage 5; Porte Droite » → « Porte Droite »). L'étage n'est plus répété dans les caractéristiques de la carte annonce : il ne figure que dans la pastille.
- ⚠ `app.js` sans hash en dev : recharger sans cache après build (sinon l'ancien JS ignore les seuils).
- Détail : `.insights/advert-locate-benchmark/README.md` s. 10.30.

### Relecture du lot Opportunites / logement (2026-09-30, soir)
- Cache des compteurs renomme `opportunity_type_counts_v2` (l'ancienne entree sans `annonce_count` aurait rendu la page en 500 pendant 30 min apres deploiement) + lectures `?? 0`.
- `ApartmentLocator` : `parseFloorText` (etages ecrits « 1er », « 2ème », « R+1 », « RDC », 54 % des anciens DPE), « Étage » majuscule reconnu (`/iu`, texte complet de la porte), « 1er étage » dans la porte lu par `floorNumber`, « Maison individuelle » exclu, `levelRank` public : le meilleur logement est retrie entre parcelles (liste et fiche annonce).
- Route `POST /acquisition/advert/apartment-hints` sortie du groupe `ACCESS_LEAD` (`planaccess:ACCESS_ADVERT`) : plus de 432 / modale d'abonnement pour un compte sans Leads ; `injectApartmentHints` ne marque que les 60 cartes envoyees.
- Badge « Annonce N % » : opportunites actives seulement ; carte annonce : commune / surface / « top 1 » corriges ; texte de `.opp-apt-chip` dans un `<span>` (ellipse).

### Top 10 de la fiche annonce : selecteur de regroupement de parcelles (2026-09-30)
- En-tete `.quick-parcels-header` : `#quickParcelsMulti` (Parcelle seule / 2 / 3 / 4 ; « Tous » retire, trop long) entre « Affiner » et la navigation. Classes `qp-multi-*` volontairement distinctes de `.parcel-option` (que `setParcelMultiValue` du panneau de filtres remet a jour globalement) ; icones `.parcel-grid` reutilisees.
- `launchQuickParcelsSearch` copie `data.filters` et y pose `parcel_multi` (absent pour 1, sinon N = ensembles d'exactement N parcelles voisines ; un « Tous » (0) choisi dans « Affiner » est laisse tel quel tant que le selecteur n'est pas touche) : la recherche principale, la seconde recherche de la fusion et la couche carte repartent de ces filtres. Tant que le selecteur n'est pas touche (`data-touched`), il reprend le regroupement deja choisi dans « Affiner ». Clic = `initQuickParcelsSearch(true)` ; le DOM etant re-rendu a chaque annonce, le choix repart a « Parcelle seule » en changeant d'annonce.
- Mise en page par container query sur `.quick-parcels-section` (largeur du bloc, donc demi-ecran compris) : > 780 px une ligne ; <= 780 px actions sous le titre, libelle « Parcelles » en icone ; <= 420 px selecteur pleine largeur puis « Affiner la recherche » + navigation.
- Recette headless `.insights/top10-multiparcel-2026-09-30/check.py <port> <token> <annonce>` : 19/19 (6 largeurs bureau / tablette / mobile en FULL et HALF sans chevauchement ni debordement ; parcel_multi = 2 / 0 envoye sur les 4 requetes du top 10, retire pour 1 ; 0 erreur JS). Le pixel de defilement horizontal en mobile existe sans le selecteur (preexistant). Deploiement : `npm run prod` + `view:clear`.

### Annonces localisees sur la carte : marqueurs photo, clic utile, mini-fiche (2026-10-03)
- Couches « Annonces localisees » (id 3) et « Opportunites » type annonce (id 4) : le clic d'un marqueur ne faisait rien (couche 3 : `code` = flxId au lieu de la parcelle ; cellules agregees a un element sans `code`) et le marqueur n'avait pas de photo (SVG Google Marker). Moteur cote core-city (`OverlayerController` couche 3 refondue + enrichissement couche 4, `overlayer/advert-markers.js`), detail dans `core-city/CLAUDE.md`.
- **1er clic** : selection de la parcelle + mini-fiche, SANS zoom (passer vite d'une annonce a l'autre). **Clic sur la meme annonce (2026-10-05)** : mini-fiche ouverte = zoom sur la parcelle n°1 + signal visuel (`is-flash`), sans recharger ni ouvrir la fiche parcelle ; mini-fiche fermee (croix, Echap) = elle se rouvre (`close()` remet `_overlayerFocusedAdvert` a null). Titre de la mini-fiche : « Annonce », et « Annonce localisée » seulement quand l'utilisateur l'a liée à la parcelle (`verified`, 2026-10-05). La fiche parcelle s'ouvre par le bouton « Fiche de la parcelle » de la mini-fiche, sur son onglet par defaut Résumé (`showParcelCard`, 2026-10-05 ; l'onglet Annonces n'apportait rien de plus que la mini-fiche). Les autres couches gardent le « 2e clic = fiche parcelle ». La photo est DANS le marqueur (vignette prix + score, compacte sous le zoom 17) pour toute annonce non regroupee, a tout zoom ; plus d'apercu au survol (retour utilisateur du 2026-10-03). Les regroupements restent des pastilles a compteur.
- **Mini-fiche** `#advertMapCard` (`resources/js/advert-map-card.js`, `window.showLocatedAdvertMapCard` / `closeLocatedAdvertMapCard`, SCSS `_advert-map-card.scss`, classes `.amc-*`) : `POST /advert/map-card` (`AdvertController::mapCard`, `planaccess:ACCESS_ADVERT`, cadastre de 14 caracteres sinon 422) -> service `App\Services\AdvertLocate\MapAdvertCard` (annonces liees par l'utilisateur = « Verifiee par vous », puis annonces dont la parcelle est n°1 du top 10 ; 8 au plus, l'annonce cliquee en tete), vue `advert/partials/_map_card`. Actions : Voir l'annonce (menu Annonces), Lead (cree ou ouvre), fiche parcelle onglet Annonces. **Score serre** (ecart n°1 - n°2 < 5 pts, annonce non verifiee) : parcelles n°2 et n°3 listees et dessinees en points orange relies par un trait pointille (`MapAdvertCard::GAP_GHOSTS`). Bureau : carte flottante en bas a gauche de la carte ; mobile : panneau au-dessus de la barre basse (56 px). Echap / croix ferment.
- Liseré de la vignette : violet (#8e44ad, couleur de la couche) si verifiee, sinon couleur du score ; une annonce dont l'utilisateur a lie la parcelle porte en plus le badge « Localisé » (`.app-located`) sur la photo, compact compris.
- Score serre : la liste `.amc-ghost` commence par la n°1 (`is_main`, « parcelle du marqueur », active par defaut, pas de point sur la carte) pour y revenir ; le bouton clique passe en `is-active`. « Voir l'annonce » ferme la mini-fiche et retire les points n°2 / n°3 (`close()` dans `openAdvert`).
- **Suite 2 : badge « Localisé » aussi sur les marqueurs de la couche Opportunites.** Le badge n'existait que sur la couche « Annonces localisees » (id 3). `OverlayerController::userLocatedParcelsByPropertie` (core-city) lit les liaisons de l'utilisateur (`pgsql_city_user`, puis cles de bien et de diffusions sur `pgsql_city` : deux bases en dev) ; un marqueur d'opportunite dont la PARCELLE porte une annonce liee par l'utilisateur passe `advert_source = verified` + `user_located = here` (meme regle que « Vérifiée par vous » de la mini-fiche, y compris quand la parcelle porte plusieurs annonces et que le marqueur en montre une autre) ; annonce liee a une autre parcelle = `user_located = elsewhere` et badge gris « Liée ailleurs ». Recette `.insights/advert-map-markers-2026-10-03/located-badge.py` **8/8** sur les 3 parcelles liees le 2026-10-03 (IV0520, IW0478 a 2 annonces, CO0072) + `browser.py` 31/31. Deploiement : tag core-city + `composer update` (site ET administration) + recopie du bundle core-city et de son hash dans les 2 `mix-manifest.json`.
- **Annonce liee par l'utilisateur (2026-10-05)** : sur la carte, son marqueur suit la parcelle de `advert_locations` (plus la parcelle n°1 du score), badge « Liée ailleurs » supprime ; la mini-fiche (`MapAdvertCard`) ne propose plus sur la parcelle n°1 une annonce que l'utilisateur a liee ailleurs. Recette `.insights/advert-map-markers-2026-10-03/relocate-linked.py` 9/9 (annonce 228130310, liee AI0149, n°1 AI0028).
- Fraicheur des scores (version 1) : `.insights/advert-locate-score-freshness-2026-10-03.php` (lecture seule, a lancer depuis administration) -> empreinte identique 100/100, n°1 identique 98/100 (2 ecarts = donnees changees) : **pas de passage en version 2**.
- Verifie : A/B `.insights/advert-map-markers-2026-10-03/` (`ab.sh` + `compare.py` : DPE et DVF identiques, couche 4 identique hors cles ajoutees, couche 3 limitee a la vue), recette Chrome headless `browser.py` **20/20** (vignettes, 1er et 2e clic, fantomes, badge verifiee, mobile 390 px, 0 erreur JS), PHPUnit 159 / 2 preexistants.
- Deploiement : tag core-city + `composer update` (site ET administration) + recopie `app-core-city.js` + hash `mix-manifest` des 2 sites + `npm run prod` + `view:clear` + `route:clear`. 0 migration.

### Menu Opportunites : effacer le lieu rafraichit la liste (2026-10-03)
- Le handler `clear` de `#map-search-btn-sector-history` (`resources/js/content-bar.js`) appelle `refreshOpportunity(false)` quand `window._currentMenu === 'opportunity'`, a cote du `refreshAdvert(true)` existant. Sans `force` : `refreshOpportunity` compare la zone a `lastCityOpportunity` et ne relance que si elle a change ; il sort seul si la liste n'est pas affichee. Changer de lieu passait deja par `blockOrUnblockFilters` (map.js), dont la branche « lieu vide » ne fait rien : c'est pour cela que l'effacement ne rafraichissait pas.
- ⚠ Verification navigateur non faite (extension Claude in Chrome deconnectee) ; bundle controle (`"opportunity"===window._currentMenu&&...refreshOpportunity(!1)` present dans `public/js/app.js`). Deploiement : `npm run prod`.

### Registre des mandats, signature et chronologies : 6 anomalies corrigees (2026-10-08)

- **Rectifier un mandat** : 23505 systematique (cf. `core-city-user/CLAUDE.md`). Cote site : `MandateRegistryController::rectify` refuse une ligne non signee (422 `not_signed`) ou deja rectifiee (422 `already_rectified`, avec le numero de la rectification) ; `App\Services\Hoguet\MandateRegisterDisplay::states()` / `stats()` = source unique des etats (en attente, sans suite, **rectifie**, actif, expire) pour le badge, les compteurs et l'export PDF. Ligne d'origine « Rectifié · par n° X », ligne RECT « rectifie n° X » ; le bouton Rectifier n'est que sur la ligne en vigueur. ⚠ **La fenetre « Rectifier le mandat » ne s'ouvrait pas** (defaut reel, pas un artefact headless) : rendue dans `#colMenu`, ouverte par `jQuery(...).modal` alors que le jQuery du site n'a pas le plugin Bootstrap (`modal` = `display:none` malgre `show`) ; promue au `<body>` et ouverte par `window.showModal`, gestionnaires globaux poses une seule fois (`window.__mandatsHandlersBound`, le fragment etant recharge). Fiche lead : prix / duree / type du mandat lus sur la derniere version (`_lead_mandats`), numero d'origine sur les documents (`GeneratesVisitVoucher`, `AcquisitionController`).
- **Compteur « Actifs »** : comptait les numeros sans suite, en attente et les lignes remplacees (jeu demo : 3 au lieu de 1).
- **Code de verification** (`/sign/{jeton}` et `/client/{jeton}/verify`) : le JavaScript postait `send-code` a l'ouverture de la page (et a chaque rechargement, apercu de lien de messagerie compris). Bouton « Recevoir mon code » ; saisie affichee directement si un code valide est en cours (`pending` / `code_pending`, heure d'envoi) ; plafond `ClientPortalPublicController::OTP_MAX_SENDS_PER_HOUR = 5` (`RateLimiter`, 429 `too_many_sends`) en plus des 30 s entre deux envois. A/B headless : ancienne vue = POST send-code a l'ouverture et `otp_sent_at` ecrit, nouvelle = 0 requete non GET et 0 ecriture.
- **Roles en anglais** sur `/sign/` (Vendor / Buyer) : `role_label` (vue, e-mail d'invitation, portail).
- **Heures de la chronologie du contact** : activites en UTC (cf. core-city-user) + `contact.js` affiche et regroupe les jours en `Europe/Paris`.
- **Carte « Dernière activité » vide dans les guides** : pas un defaut de l'application ; l'etape `&TEMPS` de `listes/crm.yml` masquait toute la carte, elle ne masque plus que l'heure relative et la ligne de compteurs.
- Jeu demo des guides : rectification par la vraie route + compromis avec verification d'identite, empreinte `0a5ff310117ca1c4c2354f38d3a509af`. Harness : `.insights/mandate-rectify-2026-10-08.php`, `.insights/otp-on-demand-2026-10-08.php`. Deploiement : packages d'abord (tag core-city-user + `composer update` site ET administration + `migrate` + `queue:restart`), puis `git pull` + `npm run prod` + `view:clear`.

### 🟥 Liens d'alerte d'annonce : la carte de la commune au lieu de la fiche (2026-10-09)

Signale par un client (2e fois) : le lien `/annonce/{id}` de l'e-mail d'alerte ouvre « uniquement la carte de la ville ». **Cause reproduite** : la disposition est memorisee par menu (`localStorage` `map_visible_advert`) et restauree au premier chargement par `showLeftMenu` ; avec « carte seule » (MAP), la fiche se chargeait dans `#colMenu` masque (0 px) et seule la carte, centree sur la commune, restait visible. Le reglage etant dans le navigateur du client, le defaut etait permanent pour lui et invisible pour nous.
- **Fiche toujours visible** : `opensRecordCard(id, customData, urlSuffix)` (`menu-map.js`, annonce / lead / contact avec id) ; au premier chargement, une preference MAP devient HALF via **`setViewMode(mode, false)`** (2e parametre `persist`, `responsive.js`) : la preference du client n'est PAS ecrasee. La bascule manuelle memorise toujours.
- **La fiche ne depend plus du recentrage** : `consumeMapLayerStart` (`map.js`) ouvrait le menu seulement dans le callback de succes de `POST /city/search/bounds` (dont `.fail` ne fait rien et qu'un appel concurrent annule). Pour une fiche (`startId > 0`) ou une 2e tentative du filet, la zone est posee (`setParamSearch`, synchrone avec titre) puis le menu s'ouvre tout de suite, le recentrage suit en parallele. Les liens de liste (recherche sauvegardee) gardent l'ordre d'origine.
- **Echec visible** : `showAdvert` (`advert_card.js`) rend un encart `.advert-load-failed` (« Reessayer », « Recharger la page » ; 400 = « n'est plus disponible ») au lieu d'un panneau vide. Annonce supprimee par l'import : `LoadController::annonce` pose `mapLayerStartMissingAdvert` → `data-missing-advert` → toast « Cette annonce n'est plus disponible » + liste des annonces.
- **Bouton Precedent** : la fiche empilait 7 fois la meme URL et chaque retour en reempilait une (popstate → `updatePage` → `showAdvert`) : on ne pouvait plus la quitter. Garde globale en tete de `app.js` : `history.pushState` vers l'URL deja affichee = `replaceState` (une entree a la meme URL n'apporte rien au retour arriere).
- Core (tag) : `AuthController::callback` (Google / Facebook / LinkedIn) fait `redirect()->intended(...)` comme la connexion par mot de passe (avant : toujours vers l'application, l'annonce etait perdue) ; e-mail groupe `mails/alert_grouped` : une seule annonce = bouton principal « Voir l'annonce ».
- Verifie : `.insights/alert-link-deeplink-2026-10-09/check.py` **20/20** (bureau, MAP conservee, mobile, mobile lent, bounds bloque, fiche en echec + Reessayer, annonce supprimee, Precedent quitte en 1 clic, lead avec MAP), `nonreg.py` **9/9** (recherche sauvegardee, bascule manuelle memorisee, navigation entre menus), `mail_grouped.php` **4/4** (rendu reel), PHPUnit 161 / 2 preexistants, 0 erreur JS, 0 log. Callback social non joue de bout en bout (exige Google).
- ⚠ Hors perimetre : l'app mobile n'a aucun lien d'application (un lien d'e-mail sur telephone ouvre le navigateur, souvent deconnecte) et une notification push a froid charge l'accueil avant l'annonce.
- Deploiement : tag core + `composer update` (site, administration, vitrine) ; site : `git pull` + `npm run prod` + `view:clear`. 0 migration, 0 route.

### Module Widgets : 13 anomalies corrigées (2026-10-09)

Relevées en rédigeant le lot 7 du centre d'aide, chacune reproduite avant correction. Moteur de capture dans core-city (cf. son CLAUDE.md).
- **Aperçu réel** : bouton « Prévisualiser » de `_distribution_section` → `GET /widgets/{uuid}/apercu` (`WidgetController::preview`, route `widgets.preview`, propriétaire seulement, 404 sinon), vue autonome `widget/preview.blade.php` (noindex) qui monte le bundle du type en MODE APERÇU (`window.__IP_WIDGET_*_PREVIEW`, celui de l'aperçu de la fiche) avec les réglages ENREGISTRÉS : réponses fictives, aucun appel API, aucun lead, aucun évènement ; bascule ordinateur / mobile (390 px), avertissement si le widget est désactivé. `/widget/test` n'est plus lié par l'interface (route conservée, citée par `_inventaire.md`).
- **Toasts** de `widget-create.js` et `_distribution_section` : `toast({message: …}, type)` (une chaîne affichait « Oups ! »). Textes de diffusion réécrits (« Adresse de la page où vous avez intégré le widget », confirmations de changement de mode).
- **Cartes de parcours** : nombre d'étapes réel par type (`_form_*`), badge du devis `data-steps-with` / `data-steps-without` selon qu'il y a des prestations. « Auto-save » → « Enregistrement automatique », mentions « (Sprint …) » retirées des textes visibles.
- **Devis : tri des prestations** par la poignée `.wt-svc-grip` réparé : `window.jQuery` n'existe pas sur cette page, `handleSortable(servicesList, …)` reçoit l'élément natif (le jQuery du core porte jQuery UI). Helper au `<body>`, enregistrement automatique après tri.
- **Opportunité** : `situationMap` avec Bailleur, `batiTypeMap = { house: 1, apartement: 7 }` (un appartement partait en 2 = maison). **DPE** : « serait classé » + `simulationNote()` (« Simulation indicative : seul le DPE établi par un diagnostiqueur certifié fait foi »), description de la modale de création sans promesse de méthode officielle. **Recherche** : le texte cite le portefeuille ET les annonces en ligne (le nombre additionne les deux). `hosted_page` : libellés de Recherche et Devis.
- **Noms de lieux en français** sur les cartes des widgets : `basemap.js` réécrit au chargement du style les `text-field` lisant `name_en` / `name:en` / `name:latin` / `name_int` en `coalesce(name:fr, original)` (OpenFreeMap ; VersaTiles et IGN déjà en français). Mesuré : Munich, Czechia, Bavaria → Bavière, Suisse, Toscane.
- **`_distribution_section`** : repli de type du nom (`_typeFallback`) aligné sur `User_widget::typeSlug` (Recherche / Devis sans nom affichaient « estimation » et le bouton de changement d'adresse).
- **Fiche lead** : KPI « Dernière activité » sans le préfixe « Modification · Modification : » ; activités chargées `created_at, id` (ordre stable à seconde égale).
- **Préférence de notification** `type_lead_portal_activity` : gouverne aussi l'e-mail `widget_lead_captured` ; libellés « Activité de vos clients et leads des widgets (email|push) » + description, par `.insights/notification-labels-widget-leads-2026-10-09.php` (simulation par défaut, `$GLOBALS['APPLY']=1`, idempotent, ciblé par `name`, à rejouer en prod).
- **Captures** : `capture.py` sert les QR `/widgets/<uuid>/qr/(svg|png)` à l'adresse de production `app.infoparcelle.fr/q/<uuid>` (chillerlan, mêmes options que le contrôleur, md5 identique au QR de prod) ; la barre d'outils de « personnaliser » est décalée de 24 px sous l'en-tête fixe (son ombre rendait la capture instable).
- Vérifié : `.insights/widget-anomalies-2026-10-09.php` 15/15 (vraie route Opportunité, transactions annulées ; ⚠ `pgsql_city_user` = préprod en dev : écritures annulées), aperçu 5 types en headless (0 erreur, 0 requête d'écriture, 390 px sans débordement), tri réel à la souris (ordre et enregistrement), captures widgets 0 réécriture sur 2 passes, crawl 0 échec, recherche 112/112, seed ×2 `32f467318fccec3f7b8c8d57399686d6`, PHPUnit 161 / 2 préexistants (3 tests de source mis à jour : le statut initial vit dans `BuildsWidgetLead::assignWidgetInitialStatus`).
- Déploiement : tags core-city + core-city-user + `composer update` (site ET administration) + `queue:restart` ; `git pull` (bundles widgets commités) + `npm run prod` + `view:clear` + `route:clear` + le script de libellés.

### Agence, équipe et conformité : 19 anomalies du lot 8 du centre d'aide (2026-10-10)

Relevées en rédigeant `resources/guides/agence/*.md`. Les anomalies 3, 4 et 22 ont été écartées par l'utilisateur.

- **🟥 Retrait d'un membre d'agence (anomalie 1)** : la corbeille de `#multiUserList` (tableau `core::team/subscription/list`, mode agence) postait `DELETE equipe/group/user-delete/{groupe}/{user}` (`TeamController::deleteGroupUser`, core) qui supprime seulement la ligne `group_users` : leads d'agence du membre non confiés au directeur, assignations conservées, mode personnel non restitué. En mode agence, `user.js` (core) rend désormais `<button data-action="remove-member" data-member-id="{role_gu_id}">` (directeur ou manager, jamais sur le directeur ni sur sa propre ligne), pris par le gestionnaire en phase capture de `setting-agency.js` → `handleRemoveMember` (une seule confirmation : le gestionnaire délégué `click.ag-rm-member`, qui doublait la fenêtre, est supprimé) → `DELETE /parametres/agence/membre/{groupUserId}`. `AgencyController::removeMember` : réponses JSON avec `message` (422 directeur / soi-même / déjà parti, 403 non-gestionnaire), et nouvelle `releaseOfferedSubscription()` = fin de l'abonnement offert par l'agence (paiement multi-utilisateur, quotas, `MultiuserDeleted`), comme le faisait la route du core. Les équipes simples gardent la route du core. 🟥 Un membre parti (`left_at`) restait affiché dans le tableau (`Group::users()` ne filtre pas `left_at`) : `HasSearch::searchUser` (core) l'exclut, valable aussi pour « Quitter l'agence ». `_members_list.blade.php` reste non inclus (rendu par `AgencyController::members`).
- **Indicateurs du hub (2)** : `AgencyDashboardController::computeKpis` / `memberStats` comptent les mandats actifs, expirant et le CA selon `MandateRegisterDisplay::states()` (en attente, sans suite et rectifiés exclus) ; « Mandats {année} » reste le nombre de lignes du registre.
- **Tableau des membres (8, 9, 10, 20, core)** : en mode agence, bouclier et « Mon abonnement » suivent le rôle réel (`full.role`), plus la puce « Manager » ; `isGroupOwner` (HasSearch) n'est pas modifié : ses seuls usages sont d'affichage dans `user.js`, mais il sert aussi les équipes simples. « Observateur » retiré du choix de rôle (`AGENCY_EDITABLE_ROLES`, masqué à l'invitation par décision produit ; un membre qui l'a déjà le voit conservé). DataTables : « Précédent / Suivant », « utilisateurs par page ». `isConnected` : `last_activity` est en secondes (connecté = activité dans les 5 dernières minutes). « A rejoint » laissé tel quel (verbe avoir, pas une faute).
- **Hub (11, 17)** : menu « ... » à `right:auto !important` en ligne (ArchitectUI impose `.dropdown-menu-right { right: 0 !important }`, et Popper pose `left:0` : avec `data-boundary="viewport"` le parent passe en `position:static` et le menu s'étirait sur 1 271 px) ; formulaire d'invitation : e-mail sur sa ligne, rôle + bouton dessous.
- **Secteurs (5, 6, 7)** : nom lu sur le bouton (`$(this).data('sector-name')`), intro en `@if` (le HTML était échappé dans `{{ }}`), accord de « appliqué » par onglet dans `_agency_note` (`$agreeByTab`).
- **Conformité (12, 13)** : « Autorité (CCI) », « séquestre » ; `agencyCanManage` passé par `setting_list` : la directrice ou un manager lit « Mentions légales de votre agence, affichées ici en lecture seule : vous les modifiez dans le périmètre Agence ».
- **Textes (15, 16, 18, 19)** : `config/pipeline_crm_integrations.php` en vouvoiement ; « token » remplacé par code / lien d'invitation (`_modal_join_agency`, infobulle de `vendor/core/team/list`, `_modal_welcome`) ; « remappés » → « replacés » ; titre de `toast` (core) : « Attention » pour un avertissement, « Information » pour une info.
- **Libellés de notification (14)** : `.insights/sql-notification-support-types-orthographe-2026-10-10.sql` (idempotent, par `name` + texte fautif) + seeders core et administration.
- **Volet des notifications (21)** : non modifié, les notifications de paiement restent listées sous « Tout ».
- Vérifié : `.insights/agence-anomalies-2026-10-10/remove-member.php` (base locale, `pgsql_city_user` redirigé, transactions annulées, Event / Mail / Notification simulés ; `HARNESS_MODE=legacy` reproduit les 3 défauts, `fixed` 15/15), `check.py` 39/39 (Chrome headless, DELETE intercepté par `Fetch.fulfillRequest`, aucune autre écriture), PHPUnit 161 / 2 préexistants. Guide `agence-et-equipe.md` : section « Retirer un membre » + capture `equipe-retirer-membre`, menu « ... » photographié ouvert, retouche de la puce « Manager » retirée de `listes/agence.yml`.
- Déploiement : tag core + `composer update` (site, administration, vitrine) + recopie de `public/core/js/app-core.js` et hash `?id=` dans les 3 `public/core/mix-manifest.json` + `queue:restart` ; `git pull` + `npm run prod` + `view:clear` ; jouer le SQL de libellés en prod ; captimmo / rootsud exclus.

### Abonnement, crédits et packs : anomalies du lot 9 du centre d'aide (2026-10-10)

Relevées en rédigeant `resources/guides/abonnement/*.md`, chacune vérifiée avant correction. Les anomalies 7 (carte signalée expirée dès le début de son dernier mois) et 8 (paiements en échec jamais listés) sont VOULUES : code inchangé, le guide présente la première comme un avertissement donné un mois à l'avance. Corrections du core : `core/CLAUDE.md`.

- **Plans API dans l'application (6)** : `includes/_api_plans.blade.php`, inclus à la fin de `_quota_recharges` (carte « Crédits & recharges » de Mon compte > Abonnement, ancre `#plans-api`, donc seulement avec un abonnement en cours). Compteur « N crédits API restants » (`ApiCreditSummary`), puis une tuile par plan API VISIBLE pour le compte (`getUserPlan($site, false, null, null, true)` : statut, `lock_visibility`, groupes ; Enterprise et Scale n'apparaissent que pour les comptes auxquels ils sont ouverts) : crédits par mois, prix HT (`PlanOffer::forPlan`), prix au crédit, bouton de `getSubscriptionLink($plan, true)` (« Souscrire » vers `/paiement/{plan}`, « Plan actuel », « Passer à ce plan »). Tuile « Essai API » (`/trial/{plan}`) seulement si `getUserPlanTrial(..., isApi: true)` le rend. 🟥 Cumul vérifié avant de coder : `PaymentController::paiement` ne redirige vers `paiement-edit` que pour un abonné de MÊME périmètre (`subscriber_active($site, $plan->is_api)`), et `createSubscriber` avec un abonné nul crée un second abonné sans toucher au premier. Le GET de `/paiement/{id}` n'appelle pas le prestataire (`getClientToken()` rend `false`). ⚠ Un clic sur « Essayer maintenant » démarre l'essai aussitôt (GET `/trial/{id}`) : jamais dans une capture.
- **Licences du hub agence (2)** : `compte/agence/manage.blade.php` comptait `$mgMembers->count()` (directeur compris) contre la quantité de l'abonnement (« 3 / 1 », « Toutes les licences sont attribuées »). Licences attribuées = membres dont l'abonnement est pris en charge (`active_multiuser_payment()` des abonnements actifs du directeur, un membre compté une fois) ; licences à offrir = quantité - 1 par abonnement (la première place est celle du directeur, règle de `TeamController::subscriptionAdd`). Démo : « 0 attribuées · 0 disponibles ».
- **Sous-titre des SMS (14)** : `_quota_recharges` lit l'extra `SMS_CREDIT` du forfait de l'abonné (« 100 SMS/mois inclus avec votre forfait CRM », « 1 200 SMS/an » en annuel, « Votre forfait Basique n'inclut pas de SMS (100 SMS/mois avec le forfait CRM) » : premier forfait proposé au compte, à la même périodicité, qui en inclut).
- **Écrans d'upgrade (13)** : `_plan_teaser_body` lit `$teaserUnlock`, le nom du module AVEC son article (« l'Agenda », « les Widgets de capture de leads », « les Leads », « les Contacts », « les Honoraires et barèmes »), passé par les 5 appelants ; repli « ce module ».
- **Données (3, 15)** : `administration/.insights/sql-packs-sms-tva-2026-10-10.sql` (+ `.sh`, `--dry-run`) pose `charge = 20` sur les packs SMS (un pack à 9,90 € HT était débité 9,90 € et facturé sans TVA) ; `.insights/sql-project-extras-apostrophes-2026-10-10.sql` rétablit les apostrophes de 7 `project_extras` (« Permet d'exporter », « l'API »...). Les deux sont rejouables, idempotents, joués en dev, À REJOUER EN PROD. Aucun seeder ne portait la faute.
- **Guide (16)** : `demarrer/faq.md`, la raison de résiliation est obligatoire.
- **Guides et captures** : `abonnement/mon-abonnement.md` et `credits-et-packs.md` mis à jour (étape « Ajoutez un plan API », essai API, fenêtre des quotas avec les recharges, montants TTC, carte d'essai, avertissement de carte, dépassements : réglage sur « Oui » à la création du compte, exports hors forfait, seuil) ; nouvelles captures `mon-abonnement-essai`, `credits-et-packs-plans-api` (+ mobile), `credits-et-packs-essai-api` ; `agence/equipe-agence` refaite. `listes/agence.yml` : la pastille de connexion des équipiers est figée à « hors ligne » dans `equipe-membres` (elle dépendait du délai depuis les captures du compte de Thomas dans `abonnement.yml`).
- Vérifié : `.insights/abonnement-anomalies-2026-10-10/` : `threshold-exceeding.sh` (legacy 8/8, fixed 15/15), `api-plan-cumul.php` 12/12, `agency-licences.php` 7/7 (vraie page rendue dans la transaction), `verif.yml` (formulaire de carte ouvert sans être rempli, rendus 390 px). Empreinte de la démo : `f30578ee5c60a70928431a1af985f80c` (le pack 500 SMS de Camille est débité 47,88 €).
- Déploiement : tag core + `composer update` (site, administration, vitrine) + recopie de `public/core/js/app-core.js` et hash `?id=` dans les 3 `public/core/mix-manifest.json` + `queue:restart` ; `git pull` + `view:clear` (aucun build du site) ; jouer les deux scripts SQL en prod ; captimmo / rootsud exclus.

### Abonnement et intégrations : corrections des lots 9 et 10 du centre d'aide (2026-10-13)

Points 9-1 à 9-10 et 10-1 à 10-12 relevés en rédigeant `resources/guides/abonnement/*.md` et `resources/guides/integrations/*.md`, chacun vérifié avant correction. Hors périmètre (décision) : 9-5, 9-7, le lien « Ajouter des licences » sur un abonnement résilié. Corrections du core (dépassements, facturation, page Accès API) : `core/CLAUDE.md` ; moteur des intégrations : `core-city-user/CLAUDE.md`.

- **🟥 Changement de modèle de pipeline : les intégrations ne sont plus perdues (10-2)** : appliquer un modèle archivait les étapes et laissait leurs intégrations attachées à des étapes archivées, donc muettes, sans rien dire. `PipelinePresetService` : `collectPerimeterWebhooks()`, `webhookTargetSlug()` (une intégration suit ses leads quand l'étape en a, sinon l'étape de même nom, sinon l'étape équivalente `autoTargetStatus()`), `reassignPerimeterWebhooks()` ; faute d'étape équivalente l'intégration est DÉSACTIVÉE et listée. Le résultat de `apply()` porte `webhooks_moved` / `webhooks_disabled`. `SettingController::presetImpact` rend `integrations` : la fenêtre « Déplacer vos leads vers le nouveau pipeline » s'ouvre aussi sans lead et affiche « Vos N intégrations suivent le changement », mis à jour quand on change une colonne de destination. Bloc « Intégrations sans étape » (`setting/automatisation/_pipeline_orphan_webhooks.blade.php`, `#pipelineOrphanWebhooks`, `SettingController::orphanPipelineWebhooks`) avec un choix d'étape et « Rattacher » : **1 route nouvelle `PUT /parametres/pipeline/webhooks/{id}/attach`** (`attachWebhook`, étape du même périmètre exigée). JS `setting-pipeline.js` (`openDisabledIntegrationsNotice`, `refreshOrphanBlock`, `.st-pipe-orphan-attach-btn`), SCSS `.st-pipe-orphans*`.
- **Test d'une intégration (10-5)** : « Envoyer un test (lead fictif) », confirmation « Envoyer un test ? » qui nomme l'adresse, puis `SettingController::testWebhook` → `PipelineWebhookDispatcher::sendTest` ; la ligne est redessinée avec la réponse. Plus aucun lead réel n'est envoyé.
- **Contenu par défaut (10-1)** : la zone « Contenu envoyé (avancé) » laissée vide envoie toutes les informations du lead (mention sous la zone) ; « Charger le contenu par défaut » reste disponible pour le modifier.
- **Changer le type d'un lead (10-7)** : `updateRequirement` appelle `autoCreateTasksForStatus($lead, $lead->status, false)` : pas de second envoi à l'outil tiers.
- **Clé API d'un widget (10-8)** : `widget/_distribution_section` ne propose que les clés `hasScope('widget')` ; note `#wt-embed-keys-excluded` « Clé non proposée : « nom ». Elle ne donne pas accès aux widgets : un widget qui l'utiliserait ne s'afficherait pas. » ⚠ Le choix de clé n'est PAS enregistré sur le widget (aucune colonne ; le sélecteur ne réécrit que le code affiché).
- **Textes (10-12, 9-8)** : « Mode d'envoi », « Adresse de réception fournie par votre outil (webhook) », « Protection de l'accès » (« Jeton (Bearer) », « Identifiant et mot de passe (Basic) », « Clé API (X-API-Key) », « En-tête personnalisé »), « Informations utilisables », assistant « Envoyer le lead quand il entre dans l'étape » (`_pipeline_status_card`, `_pipeline_webhook_item`, `_preset_wizard`, `config/pipeline_crm_integrations.php`) ; « Abonnement actuel » (`includes/_api_plans`, vitrine `pricing-card`).
- **Guides** (`verifie_le: 2026-10-13`) : `integrations/webhooks.md` (réécrit), `integrations/api.md`, `integrations/mcp.md`, `agence/pipeline.md`, `abonnement/mon-abonnement.md`, `abonnement/credits-et-packs.md`, `agence/agence-et-equipe.md`, `widgets/creer-et-diffuser.md` ; `_inventaire.md` daté. Captures refaites : `integrations.yml`, `abonnement.yml`, `agence.yml`, `widgets.yml` : 17 images au contenu différent, 10 par les corrections et 7 parce que la démo a été remise à zéro un autre jour (dates d'abonnement, courbe d'activité, tableau de bord) ; passes suivantes : 0 réécriture (88 images). ⚠ Les autres listes n'ont PAS été réécrites : rejouées dans un dossier à part, 44 images sur 147 diffèrent par leurs seules dates et `visites-compte-rendu` échoue tant que la visite du mardi 14 h 30 de la démo n'est pas passée.
- Contrôles : recherche 159/159 (serveur et navigateur), exploration 66 pages / 65 liens internes / 225 médias / 0 échec (comptes 990001 et 990004), PHPUnit 161 / 2 préexistants, seed ×2 = `27ea932e0e590d2756f9b20da0640230`, vitrine `/tarifs` identique, vendors alignés. Une ligne ERROR du jour (10:12, `Target class [...UserController] does not exist`, compte de démo) : requête arrivée pendant le remplacement du fichier par le harness A/B, pas un défaut du code.
- **Tests de source mis à jour** (nouvelles signatures) : `LeadTypeChangeCleanupTest`, `PipelineTaskRequirementTypeTest`. `help-center-2026-10-07/queries.json` : « tester maintenant » (ancien libellé) → « envoyer un test ».
- **Harness** `.insights/anomalies-lots-9-10-2026-10-13/` : `usequota-matrix.sh` (15 profils, 3 jeux de résultats), `signup-exceeding-default.sh` (15/15 puis 15/15), `threshold-team.sh` (14/14 puis 14/14), `licence-counters.sh` (9/10 puis 10/10), `integrations.sh both` (legacy 16/16, fixed 57/57), `integrations-screen.py` 33/33, `api-screen.py` 18/18, `api-activity.sh` (3/3 puis 6/6), `browser.py` (pilote Chrome headless : écritures interceptées), `fingerprint-week.py`, `quota-popup-timing.sh` 31/31 (complément du 9-1 : la fenêtre « changer de moyen de paiement » ne s'ouvre QUE quand on utilise un quota à hors-forfait épuisé - export ou crédit API, les deux seuls quotas à hors-forfait du projet ; jamais tant qu'il reste du quota, jamais au chargement d'une page, y compris pour un abonnement résilié encore valable à carte inactive : 5 profils à quota disponible, séquence 200 / 200 / 433, 6 vraies pages rendues avant et après épuisement, puis `POST /action/csv` = 433 ; connexions distantes en lecture seule pour la session, `pgsql_root` en transaction annulée).
- **Fin de la création d'un client OAuth2** (core, retour utilisateur du même jour) : bouton « Terminer » et rechargement du tableau « Clients OAuth2 » à la fermeture de la fenêtre des identifiants ; « Fermer » sans rechargement quand elle est ouverte depuis le tableau. `oauth-credentials-finish.py` 12/12. Anomalies rejouées le même jour : `api-tab-submit.py` (formulaires de la page API en GET par onglet seulement, accès direct correct) et `widget-copy-after-key-change.py` (« Copier » garde l'ancien code après un changement de clé ; le sélecteur n'existe qu'à partir de 2 clés utilisables par un widget).
- ⚠ **L'empreinte de la démo dépend de la semaine** (dates des rendez-vous, des visites et des biens consultés) : semaine du 12 octobre = `27ea932e0e590d2756f9b20da0640230`, identique à `2b41f12b6dec3d059b7f0834af29f575` une fois ces dates reculées de 7 jours (`fingerprint-week.py`). Noté dans `.insights/guides/README.md`.
- ⚠ **Non corrigés, signalés** : formulaires « Créer la clé » / « Créer le client OAuth2 » soumis en GET sur la page API chargée par onglet (core `user.js`) ; bouton « Copier le code » d'une fiche de widget (`_form_*.blade.php` l. 441 à 514 cherchent `#panel-embed .copy-to-clipboard`, classe que le bouton ne porte pas : lu, non exécuté) ; archiver UNE étape laisse ses intégrations sans étape (visibles dans le bloc, non reportées) et `updateWebhook` accepte un `lead_status_id` sans contrôle d'accès sur l'étape ; alerte « Toutes les licences sont attribuées » du hub non rafraîchie sans rechargement. Aussi (core) : `GET /compte/oauth-tokens` répond 500 depuis sa création (vue `oauth.tokens` jamais écrite, aucun lien n'y mène) ; le tableau « Clients OAuth2 » de `/compte/api` liste et laisse supprimer le client d'accès personnel de Passport quand il appartient au compte connecté (en base locale « Gamesper Desktop », compte 2 : il porte les jetons de toutes les clés API).
- Déploiement, **packages AVANT le site** (il appelle `PipelineWebhookDispatcher::defaultPayloadTemplate()`, `sendTest()`, `ApiAccessController::mcpUrls()`) : tags core + core-city-user, `composer update` (site, administration ; vitrine pour core), `queue:restart`, recopie de `app-core.js` + hash ; puis `git pull` + `npm run prod` + `view:clear` + `route:clear` ; vitrine : `git pull` + `view:clear` ; administration : `git pull`. 0 migration, 1 route, 0 script de données ; captimmo / rootsud exclus.

### Lots 9 et 10 du centre d'aide, seconde passe : anomalies restantes et horloge de référence des guides (2026-10-13)

Validation utilisateur du même jour : tout corriger, sauf le lien « Ajouter des licences » d'un abonnement résilié (laissé tel quel) et la suppression en cascade d'un client OAuth2 (exclue). Corrections du core et de core-city-user : leurs CLAUDE.md.

- **Archiver une étape qui porte des intégrations** : `SettingController::archiveStatus` les fait suivre les leads vers l'étape de remplacement ; sans lead à déplacer elles sont désactivées et listées dans « Intégrations sans étape ». Réponse `webhooks_moved` / `webhooks_disabled`, 422 `reassign_required` enrichi de `integrations`. `updateWebhook` contrôle l'accès à l'étape (non archivée, même périmètre) quand elle change. `setting-pipeline.js` : confirmation qui l'annonce, note dans la fenêtre de remplacement, message et avis après archivage, rechargement de l'onglet. `archive-status-webhooks.sh` : legacy 13/13 (défaut reproduit), fixed 20/20 ; `archive-screen.py` 15/15 (écritures interceptées).
- **Bouton « Copier le code » d'une fiche de widget** : les 5 `widget/_form_*.blade.php` visaient `#panel-embed .copy-to-clipboard`, classe que le bouton ne porte pas ; ils visent `#panel-embed .wt-code-action--primary[data-clipboard-text]`. `widget-copy-after-key-change.py` : après un changement de clé, le texte copié est le code affiché.
- **« Abonnement actuel »** du bloc « Plans API » (et de la carte tarifaire de la vitrine) : lu sur `is_current` (core), plus sur un libellé.
- **Alerte de licences du hub** : `compte/agence/manage.blade.php` pose `data-alert-full`, `data-alert-partial`, `data-can-upgrade` ; le core réécrit l'alerte sans rechargement avec le texte du serveur et ne montre « Ajouter des licences » qu'au propriétaire de l'abonnement. `licence-alert.sh` : legacy 12/13, fixed 14/14 ; `licence-counters.sh` 10/10.
- **« Tâches automatiques nettoyées »** (`AcquisitionController`) : métadonnées passées en tableau (cf. core-city-user).
- **Harness du 2026-10-10** : `agence-anomalies-2026-10-10/remove-member.php` garde par `pg_postmaster_start_time()` (rejoué : fixed 15/15).
- **Guides** : `integrations/webhooks.md` (section « Quand vous archivez une étape », question fréquente élargie) et `agence/pipeline.md`.
- **🟥 Horloge de référence des guides** (`.insights/guides/demo.sh`, `captures/horloge.py`, `captures/horloge/reference.txt`, `routeur.php`, `fuites.php`) : la démonstration est remise à zéro ET photographiée au mercredi 14 octobre 2026 à 10 h, quel que soit le jour réel. Rien n'est modifié dans l'application, Apache ni le conteneur : un serveur PHP dédié (`php -S`, un port par session) est lancé le temps de la passe sous une horloge décalée (libfaketime, extraite dans `/tmp/guide-horloge`), joint par un relais HTTPS local ; le navigateur des captures reçoit le même décalage. `capture.py --horloge reelle` et `demo.sh reset --reelle` gardent l'ancien mode (parcourir la démonstration à la main). Garde : `capture.py` refuse de photographier une démonstration préparée pour une autre semaine que celle de son horloge. Détail et limites : `.insights/guides/README.md`.
  - 🟥 **Incident pendant la mise au point** : le serveur dédié, en avance de 18 h, utilisait la table `core.sessions` partagée avec Apache ; son ramasse-miettes a supprimé les sessions RÉELLES de l'application de développement (reconnexion nécessaire). Corrigé : sessions en fichiers avec une durée de vie de dix ans (`SESSION_DRIVER=file`, `SESSION_LIFETIME=5256000`), cache en mémoire, journal à part. Écartés après essai : la durée de vie normale (la date des fichiers n'est pas décalée pour PHP, session jugée expirée à chaque requête, POST en 419) et le pilote `cookie` (session de plus de 4 096 octets sur certains comptes, cookie ignoré par le navigateur). Contrôle : `select count(*) filter (where last_activity > extract(epoch from now()) + 600) from core.sessions` = 0.
  - **Empreinte indépendante de la semaine** : `seed-demo-guide.php` note le lundi de la démonstration (`demo_guide_week`) et exprime les dates de l'agenda, des visites et des biens consultés par rapport à lui. `525ffa05000769dc308d51031f86fc35` (était `27ea932e0e590d2756f9b20da0640230` : même contenu, autre écriture des dates), obtenue sur la semaine en cours, sur une remise à zéro à l'horloge du 16 septembre et sur cinq remises à zéro à la date de référence.
  - **Identifiants stables** (`IDENTIFIANTS_STABLES` de `capture.py`) : numéros de lead et d'estimation (séquences de la base) et nom du PDF d'avis de valeur (code aléatoire, heure à la seconde) changeaient à chaque remise à zéro, 4 images réécrites pour les mêmes données ; ils sont ramenés au rang dans la démonstration depuis une base fixe.
  - `listes/agence.yml` : pastille de Camille figée à « en ligne » (le serveur dédié n'écrit pas dans `core.sessions`). La retouche d'ordre de la fenêtre des quotas (`listes/abonnement.yml`) a été retirée le soir même : le core trie désormais ces lignes (voir ci-dessous).
  - **Captures** : passe 1 à la date de référence = 236 captures, 52 réécrites (dates), 0 échec ; après remise à zéro, passe 2 = 6 réécrites (ordre des quotas, identifiants de la base, nom du PDF : corrigés par les deux points ci-dessus) ; après remise à zéro, passe 3 = 236 captures, 2 réécrites (les deux retouches voulues), 0 échec ; après une cinquième remise à zéro, 75 captures rejouées (listes abonnement et agence, captures datées du CRM, de l'agenda et du démarrage) : 0 réécriture. Une capture de l'agenda a échoué une fois comme PREMIÈRE capture d'une session (serveur à froid), puis 4 fois sur 4 réussie : le serveur est désormais chauffé au lancement, une lecture sans réponse est rejouée une fois par le relais et toute requête perdue est signalée en fin de passe.
- **Corrigés le soir même sur validation (core, `UserController`, 0 fichier du site)** : `/compte/equipe/{id}` avec une équipe introuvable ou un identifiant illisible ramène à la liste des équipes (500 avant) ; la fenêtre « Quotas en cours d'utilisation » a un ordre stable (lignes dans l'ordre des cartes de forfait, le forfait puis le plan API puis les recharges dans l'ordre d'achat). Détail : `core/CLAUDE.md`. Harness `.insights/anomalies-lots-9-10-2026-10-13/team-missing-quota-order.sh` (legacy 19 OK / 13 défauts reproduits, fixed 32/32) et `team-missing-quota-order-screen.py` (navigateur jetable sur l'application réelle : legacy 6 / 4 défauts, fixed 10/10). Capture `mon-abonnement-quotas` refaite sans retouche (exports, SMS, crédits API).
- **Corrigé le 2026-10-14 (core, `UserController::getQuota`)** : `GET /compte/quota/{id?}/{idUser?}` rendait la fenêtre des quotas de N'IMPORTE QUEL compte quand `idUser` était fourni. `idUser` n'est plus accepté que pour soi-même ou pour un membre dont un abonnement du demandeur prend en charge la licence (règle du bouton « Quotas » du tableau des membres), sinon 400 comme un compte inconnu ; identifiant illisible = 400 (500 avant). Harness `.insights/anomalies-lots-9-10-2026-10-13/quota-other-account.sh` (legacy 5 OK / 9 défauts, fixed 14/14) et `quota-other-account-screen.py` (legacy : pack SMS de Camille lu par le compte Basique ; fixed 6/6). Détail : `core/CLAUDE.md`.
- Déploiement : celui de la section précédente (packages AVANT le site), plus `route:clear` pour le core (3 routes retirées). L'outillage des guides (`.insights/guides/`) ne se déploie pas.

### Relecture complète du 2026-10-14 : retouches du site

- `SettingController::attachWebhook` : l'intégration prend le périmètre de l'étape (scope, `group_id`, `user_id`), comme `storeWebhook` ; sans cela une intégration d'agence rattachée à une étape personnelle (ou l'inverse) ne partait jamais (`getEffectiveForLead`). `integrations.sh` 62/62.
- `setting-agency.js` : gestionnaires délégués `click.ag-leave` / `ag-archive` / `ag-reactivate` / `ag-view-members` / `ag-open-mode` retirés, ils doublaient la phase capture (2 confirmations pour « Quitter », 2 POST pour « Réactiver ») ; `handleReactivateAgency` et `handleViewMembers` gagnent leur message d'erreur.
- `contact.js` : la fermeture de `#modalContact` avance `_contactModalSeq` (une réponse « Modifier » arrivée après la fermeture ne repasse plus la fenêtre en modification). `map.js` : `consumeMapLayerStart` ne rappelle pas `showMenu` si le filet l'a déjà fait (`_menuShown`).
- `_lead_proposals` : prix, durée et type du mandat lus sur `latestVersion()` (rectification), numéro d'origine conservé. Intégration GET sans contenu : textes de `_pipeline_status_card`, `setting-pipeline.js` et du guide `integrations/webhooks.md` alignés. `_api_plans` : repli « Abonnement actuel » insensible à la casse.
- Vérifié : recettes listées dans le journal racine (Maj 2026-10-14, 2). Build `npm run prod`. ⚠ Déploiement : packages (core, core-city, core-city-user) taggés et `composer update` AVANT le `git pull` du site, le `composer.lock` commité devant viser ces tags. ⚠ Ne pas committer `.env.bak-sms-2026-08-03` ni `storage/slow1.php`.

### Avenants au registre des mandats et mandataire de réseau (2026-10-14)

Moteur dans core-city-user (cf. son CLAUDE.md). Côté site :
- **`AcquisitionController::generateAvenantMandat`** : 422 `mandate_not_registered` si le mandat n'est pas signé au registre, 409 `avenant_pending` si un avenant attend déjà sa signature ; métadonnées de l'avenant = SES conditions (plus de recopie des preuves ni des variables du mandat) + `avenant_of_proposal_id`, `avenant_motif`, `external_avenant_number` ; `registerAvenantPending` appelé juste après la création (échec = document supprimé, 500 `register_failed`) ; `metadata.template_vars` de l'avenant enregistrées AVANT le PDF (le PDF signé en ligne garde les modifications) ; réponse `avenant_number`.
- `avenant_mandat` ajouté aux listes `$isMandate` de `ClientPortalPublicController::signProposal` et `markProposalSignedManual` : la ligne est scellée à la signature.
- **Registre** (`MandateRegisterDisplay`) : état `amended` « Modifié par avenant » (`amended_by` = numéro de la ligne en vigueur), filtre de statut, libellé « Avenant au n° … » (écran et export PDF), numéro réseau du mandat dans l'infobulle AV. `MandateRegistryController::rectify` : 422 `amended` sur un mandat remplacé par un avenant (rectifier la dernière ligne).
- **Fiche lead** : `_lead_mandats` lit `inForceVersion()` (puce « Modifié par avenant n° … »), bouton « Générer un avenant » désactivé tant qu'un avenant attend (texte qui le nomme) ; `_lead_avenant_item` : numéro au registre, numéro réseau, « Sans suite » si la ligne est en attente, badge « Sans suite » ; `_lead_proposals` : conditions en vigueur.
- **Mandataire de réseau** : la fenêtre d'avenant inclut `_external_mandate_number_field` (`emnForAvenant`, libellé « N° de l'avenant au registre de votre réseau », id `gavExternalNumber`) ; bandeau avec le numéro du mandat et son numéro réseau ; `phase2-actions.js` envoie `external_mandate_number` et annonce le numéro réservé.
- Guide `crm/registre-des-mandats.md` : section « Modifier un mandat par avenant » + encadré réseau ; capture `documents-et-modeles-pieces` refaite (texte du bouton).
- Vérifié : `.insights/avenants-registre-2026-10-14/avenants.sh` **71/71** (génération, 409, signature papier et en ligne, scellement et chaîne, états et compteurs, documents suivants, prévision, rectification, sans suite, avenant non inscrit avant correctif, refus sur mandat non signé, mandataire de réseau, alerte) ; `render.php` 7/7 (fiche lead 614 et registre du compte 2 sur la base locale) ; fenêtre rendue pour un réseau et une agence ; recherche du centre d'aide 161/161 ; PHPUnit 161 / 2 préexistants.
- **Rattrapage À REJOUER EN PROD** : `.insights/register-signed-avenants-2026-10-14.php` (simulation par défaut, `$GLOBALS['APPLY']=1`, idempotent ; avenants déjà signés seulement, inscrits avec leur vraie date de signature et `late_registration`).
- Déploiement : tag core-city-user + `composer update` (site ET administration) + `queue:restart` AVANT le site ; puis `git pull` + `npm run prod` + `view:clear` ; puis le rattrapage. 0 migration, 0 route.


### Défauts relevés par la relecture du centre d'aide : corrigés (2026-10-14)

Exceptions décidées par l'utilisateur : case de consentement pré-cochée des widgets (inchangée) ; bouton « Envoyer » de l'estimation visible sans CRM (écran d'upgrade, laissé).
- **Toasts** : `toast(null, type, 'texte')`, jamais `toast('texte', type)` (une chaîne en 1er argument affiche « Oups ! ») : `agenda.js`, `welcome-onboarding.js`, `dashboard.js`, `tab-estim-swp.js`, `feedback.js`, core-city `overlayer/library.js`.
- **Widgets Estimation / Opportunité sans données de prix** : le lead est toujours créé (note « Résultat non calculé »), réponse 200 `data.no_data` → écran `renderNoDataResult` (`resources/js/widget/src/helpers.js`). Moteur dans core-city. Harness `.insights/defauts-application-2026-10-14/widget-sans-donnees.php` 16/16.
- **Libellés** : « espace client » partout à l'écran (plus « portail client », « Lien portail créé », « Visible portail ») ; onglet « Modèles » de /parametres (`data-tab="templates"` inchangé) ; « Étape personnelle » (assigné qui place un lead dans une étape propre à son pipeline) ; civilité « M. » ; accents de la surcharge `vendor/core/team/modal_csv_import.blade.php` (le site surcharge la vue du core : corriger les deux).
- **Nombres** : `pricemap.js` `_fmtDec` / `_fmtPct` (virgule, espace insécable avant « % », volumes `_formatNumber`, tension à 2 décimales aussi dans l'échelle et la valeur active) ; fiscalité, estimation, comparateur d'annonces, distances, tailles de fichiers au format français.
- **Pastilles des recherches d'annonces** (`advert_filters_tags`) : `apartment` et `Room` reconnus ; `roomsMobile` = « Nombre de pièces ». Le jeu de démonstration utilise désormais les clés réelles du formulaire (`flat`, `roomsMin`).
- **Intégrations d'une étape d'agence** vues par un membre qui ne gère pas l'agence : lecture seule (`_pipeline_status_card` : note « gérées par le directeur ou un manager », pas d'ajout ni de formulaire ; `_pipeline_webhook_item` : interrupteur désactivé, pas d'actions, **en-têtes et contenu non écrits dans la page** - ils contenaient le jeton d'accès à l'outil tiers). Le serveur refusait déjà en 403 (`authorizeSettingRow`).
- **Signature avancée** : plus aucun nom de prestataire à l'écran ni dans les messages, titres de documents et activités ; libellé de l'extra `YOUSIGN_CREDIT` par `.insights/sql-project-extras-signature-avancee-2026-10-14.sql` (À REJOUER EN PROD).
- **Honoraires de recherche** : `.insights/sql-fee-structures-recherche-2026-10-14.sql` crée les barèmes « Recherche achat » (2) et « Recherche location » (3) par copie de Vente / Location, idempotent, colonnes communes (la préproduction n'a pas `option_pinel*`). Joué en local (0 changement) et en PRÉPROD ; l'utilisateur les ajoute lui-même en production.
- Pastilles des recherches d'annonces : montants et surfaces au format français (« Prix 150 000 € - 320 000 € », « 85,5 m² »), années non formatées. Fenêtre des horaires : « pause déjeuner » en entier.
- ⚠ Non modifié : `.cardEstimatePricePpmValue` (prix au m² de l'estimation) reste sans séparateur de milliers - son texte est relu par `parseInt` dans `tab-estim.js` et `unified-estimate.js` (5 lectures) : formater l'affichage imposerait de reprendre ces lectures.
- Divers : volet des notifications sans « paiements » ; Ayants-droit « Copropriétés du registre national » ; exemples de réseaux sans noms de partenaires ; aperçu Apparence (lien vers l'onglet Conformité, « Infoparcelle » au lieu du nom technique de l'application) ; libellés de fréquence des alertes accordés.
- Guides alignés : `carte/couches.md` (famille Urbanisme), `crm/honoraires.md`, `crm/documents-et-modeles.md`, `demarrer/parametrer-son-compte.md`, `demarrer/glossaire.md`, `agence/notifications-et-preferences.md`, `agenda/horaires-et-rappels.md`, `crm/partager-un-lead.md`, `agence/agence-et-equipe.md`.
- Vérifié : PHPUnit 161 / 2 préexistants, recherche 161/161, Paramètres rendus en session (4 barèmes, « Modèles »), intégration d'agence directrice / membre, démo ×2 `12e64f2c2718cfc1dbb44c032c102bef`. Déploiement : packages (core, core-city, core-city-user) AVANT le site, recopie des bundles core (`763cf215…`) et core-city (`22f560c2…`), `npm run prod`, `view:clear`, SQL de libellé.

### Fenêtre d'accueil préremplie par le profil déclaré (2026-11-02, lot 1 du plan outils gratuits)

- `vendor.blade.php` expose `window._onboarding.declaredProfile` (`SignupProfile::declared`, core, réglage `declared_profile` posé par la fenêtre d'inscription de la vitrine).
- `welcome-onboarding.js` `applyDeclaredProfile()` (étape 1, compte sans mode configuré) : rappel `#woDeclaredHint` ; particulier ou autre professionnel précoché (le métier s'affiche) ; professionnel de l'immobilier = particulier et prestataire repliés (`#woModeForm.is-immo-only`, attribut `data-wo-nonimmo`) avec le lien `#woShowAllModes`. Rien n'est envoyé avant « Continuer ». « creer » sans forfait équipe et « rejoindre » postent désormais `/parametres/mode` (sans attente) pour enregistrer le profil ; `dataLayer` `profile_selected` (`user_profile`, `welcome_mode`).
- `AgencyController::updateMode` appelle `SignupProfile::declare()` (garde `class_exists`) pour tout choix : un choix professionnel après un clic Google part à Google Ads (« Inscription professionnelle (serveur) », core).
- Bancs : `.insights/lot1-profil-2026-11-02/` (README). Déploiement : après le tag core, `git pull` + `npm run prod` + `view:clear`.

### Ancienne documentation sous /api/guide et API explorée par Google (2026-11-04, audit SEO de la vitrine n° 35)

- `public/.htaccess` : `/api/guide/*` et `/guide/*` → 301 vers `docs.<domaine>` (même chemin) quand l'hôte commence
  par `app.` ; idem `/getting-started`, `/guides`, `/api-reference`, `/sdks`, `/resources`, `/redocusaurus`, `/intro`
  (liens de l'ancienne doc construite pour la racine : 26 pages en 404 dans la Search Console). La route
  `docusaurus.show` du core n'est plus atteinte sur `app.*`. ⚠ Ne pas créer de route applicative sous ces préfixes.
- `public/robots.txt` : `Disallow: /api/v1/` et `/oauth/` (30 adresses d'API en 401 explorées). `/api/guide` reste
  lisible pour que les moteurs voient les redirections.
