# Rapport — v3.29.0 (affichage réparé, l'étoile déclenche l'analyse complète, UI/UX au propre)

**Se livre aux DEUX installations, à l'identique. AUCUNE migration, aucune dépendance composer,
aucun asset compilé — le CSS reste statique dans `public/`.**

> ⚠️ **Tout appel IA passe par la file** (`RunAiAnalysis`), jamais dans la requête web. Et l'IA
> payante reste réservée aux AO **étoilés** — règle tranchée : la masse (veille ordinaire,
> clôturés, historique) ne coûte rien.

---

## Le problème

Quatre chantiers, tous constatés **en direct sur la prod** (audit navigateur du 28/08, veille à
235 AO qui grossit de dizaines par jour) :

1. **§A** — la v3.28.0 a produit l'inverse de « dense » : des lignes de **215 px** (~4 AO par
   écran), parce qu'un nom d'acheteur long s'empilait sur ~10 lignes ;
2. **§B** — la fiche `/veille/73` faisait **9 244 px**, dont un bloc « Notes » de 4 709 px : le
   texte brut du dossier déversé en un mur ;
3. **§C** — étoiler un AO — le geste payant — ne produisait que le dossier de préparation
   (5 clés) : ni GO/NO-GO, ni exigences, ni vigilance, ni projection, ni conseil de prix, alors
   que la fiche (VOLET E) sait tout afficher ;
4. **§D** — sept frictions relevées à l'écran, de la cloche à 64 non-lues aux trois boutons
   jumeaux de l'entête.

## §A — La liste vraiment dense (hotfix)

**La cause, mesurée.** Dans `public/css/socle-dense.css`, `overflow-wrap: anywhere` autorisait la
colonne Acheteur à s'effondrer à ~86 px — « MINISTÈRE DE LA SOLIDARITÉ, DE L'INSERTION SOCIALE ET
DE LA FAMILLE – RABAT » s'écrivait sur ~10 lignes → cellule 214 px → ligne 215 px. Et la densité
v3.28.0 ne s'appliquait pas : elle ciblait `.fi-ta-cell` (qui est à `p-0` — elle ne peint rien) et
la table (dont la police est re-fixée plus bas par `.fi-ta-text-item.fi-size-sm` → 14 px,
interligne FIXE 1,5 rem). **Correctif validé en direct : 215 px → ~79 px.**

Deux niveaux :

- **Filament** — sur les 3 tables d'AO, `buyer_name` ne wrappe jamais : `->wrap(false)` +
  `->limit(TablesDenses::ACHETEUR)` (45) + `->tooltip()` (le nom entier au survol). L'objet avait
  déjà son `limit(60)` + tooltip.
- **CSS** — `overflow-wrap: anywhere` est REMPLACÉ par une troncature une-ligne
  (`white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 22rem` sur
  `.fi-ta-table .fi-ta-cell .fi-ta-text-item`), et la densité cible les éléments que Filament
  PEINT : police/interligne sur `.fi-ta-text-item` (spécificité qui l'emporte sur `.fi-size-sm`),
  padding sur `.fi-ta-text:not(.fi-inline)` / `.fi-ta-icon:not(.fi-inline)` (c'était le
  `px-3 py-4` de ces conteneurs qui faisait le plancher de hauteur — icônes favori/vu et date
  comprises), cellules d'actions et de sélection alignées. Gouttière gauche : 1 rem — réduite,
  pas nulle.

**Acquis v3.28.0 conservés** : entête de colonnes figée (fond opaque clair/sombre), boutons jamais
coupés (flex-wrap + overflow visible — le SEUL `overflow: hidden` de la feuille est la troncature
de cellule, et le test le compte), 25/50/100/200 lignes (défaut 50).

## §B — La fiche : plus de mur de texte

**La cause, lue dans le code.** La règle v3.27.0 des « notes humaines » (« ne pas afficher si ça
commence par `=====` ») laissait passer le cas réel : le **bloc source du portail** est rangé EN
TÊTE des notes SANS séparateur, les blocs `===== AVIS ===== / ===== DOSSIER =====` s'empilent
dessous — le tout passait pour une note humaine et se déversait en bas de fiche.

**Décision du gérant : le brut reste lisible EN ENTIER, mais replié.**

- `SectionsAo::notesHumaines()` coupe au premier séparateur et refuse une tête qui est un bloc
  source (`VeilleBlocSource::estBlocSource()`) : les « Notes » ne montrent QUE l'écrit humain ;
- nouvelle section partagée `SectionsAo::texteIntegralDossier()` — intitulé « **Texte intégral du
  dossier (portail / OCR)** », `->collapsible()->collapsed()`, posée EN BAS des deux fiches
  (`VeilleInfolist` et `TenderInfolist`). Même dépliée, pas un mur : son corps est borné
  (`max-height: 60vh; overflow-y: auto` via `socle-dense.css`, classe `socle-texte-integral`) ;
- **ordre façon PMMP** : l'essentiel labellisé en tête (Objet EN ENTIER — la fiche ne tronque pas,
  Référence, Date limite, Acheteur), les sections structurées, le brut replié en bas. On réordonne
  et on replie — rien n'est masqué, rien n'est cassé.

## §C — L'étoile déclenche l'analyse COMPLÈTE

**Objectif : étoiler = le même contenu qu'un AO en préparation.** La chaîne
`VeilleDossier` (texte OCR de la moissonneuse → `VeilleExtractionService` →
`DossierEtoileService`) va désormais **jusqu'au bout** :

1. **§C1 — `ai_analysis` riche.** `extraireDossierDuTexte()` demande et nettoie, en plus des
   7 champs v3.24.0, les champs `CHAMPS_DOSSIER_RICHES` : `decision` {level, condition,
   recommendation, reasons, requirements, vigilance} — la note TRANCHE, comme `TenderAnalyze` —,
   `eligibility` (marché réservé art. 148), `agrement`, `references_required`, `team`,
   `execution_places`, `caution_provisoire`, `penalites_retard`. `DossierEtoileService::appliquer()`
   les fusionne clé par clé, **uniquement si la clé est vide** — une analyse d'AO en préparation
   déjà faite n'est jamais défaite —, puis appelle **`TenderDecisionService::seed()`** : le
   `decision_aid` (GO/NO-GO, exigences par dossier, vigilance) en découle. `seed()` garde sa
   garde : un `decision_aid` marqué `edited` n'est jamais touché.
2. **§C2 — la projection.** `DossierEtoileService::projeter()` — appelé par `appliquer()` et par
   la voie `TenderAnalyze` d'un étoilé — réutilise `TenderProjectionService` +
   `TenderProjectionAiService` et remplit `ai_projection` / `ai_projection_at`. **Gardes v3.16
   intactes** : candidats et prix attendus depuis la base uniquement, `recaler()` écarte tout nom
   inventé et relève la cible au plancher. **Précision veille** : pas de bordereau chiffré →
   `plancherDeMarge()` se replie tel quel (prudentiel sur l'estimation → sinon aucun plancher,
   libellé explicite) — et `recaler()` ajoute désormais TOUJOURS ce libellé à la justification du
   prix conseillé : le lecteur sait sur quelle base il repose. Base vide (RUWAD) : projection
   déterministe « historique insuffisant », **zéro appel au modèle**. Un échec de projection ne
   défait jamais la lecture du dossier (journalisé, la fiche garde analyse + décision).
3. **§C3 — affichage.** Rien à faire : `SectionsDossierIa` rendait déjà tout — c'est la donnée qui
   manquait. Vérifié par test : la fiche d'un étoilé analysé montre GO/NO-GO + exigences +
   vigilance + projection + conseil de prix, comme un AO en préparation.
4. **§C4 — déclencheurs.** L'étoile fait entrer l'AO dans `GET /api/veille/etoiles-a-approfondir`
   (inchangé) ; la chaîne déversée va au bout. Le bouton « Analyser le dossier (IA) » de la fiche
   Veille (`TenderAnalyze`) enchaîne lui aussi sur la projection quand la fiche est une veille
   étoilée. Retirer l'étoile n'efface rien. Un AO **non étoilé** ne déclenche RIEN.
5. **Dépendance VPS** — la moissonneuse doit ramener le **dossier complet** dans le champ
   `dossier` (le gérant s'en occupe) ; côté app le texte est consommé en entier (le champ
   `options` du run est un JSON/LONGTEXT, seul le plafond générique
   `services.ai.max_document_chars` s'applique, annoncé au modèle).

## §D — Les frictions de l'audit

1. **Notifications (tranché)** — `EvenementsPortailService::detecter()` porte une garde
   `estSuivi()` : les événements portail ne notifient que les AO **étoilés** ou **du parcours**
   (`favori || ! estVeille()`). La veille non étoilée : silence — l'ingestion écrit toujours
   l'événement sur la fiche (notes, date, statut). « Ne plus notifier » prime toujours (en aval,
   `AlertesAoService`). « Nouvel AO » garde son filtre par familles prioritaires.
2. **Colonnes mortes** — Veille : « Résultat » et « Concurrence » en
   `toggleable(isToggledHiddenByDefault: true)` ; « AO clôturés » les garde visibles. Le filtre
   « Résultat connu ? » quitte la Veille et vit sur les clôturés (`FiltresAo::resultatConnu()`).
3. **Entête en 3 entrées** — « + Nouvel AO en veille » (primaire), menu « Alimenter la veille »,
   menu « **Interprétation (IA)** » (« Lancer une interprétation » · « Dernière interprétation »).
   Fini les trois libellés jumeaux ; aucune action supprimée. Même renommage sur « AO clôturés ».
4. **Colonne « Famille »** — `ColonneFamille::make()` : badge compact, couleur stable par famille,
   alimentée par `familleAffichee()` (nature du marché comprise : un marché de fournitures
   s'affiche « Fournitures »), `->toggleable()` visible par défaut — Veille et clôturés.
5. **Fiche d'un AO ouvert** — « Résultat observé & concurrence » ne s'affiche que si l'AO est
   clôturé (`deposit_date` passée) ou si des données existent (vainqueur, plis annoncés, plis
   saisis).
6. **« Analyse terminée » dit de qui elle parle** — `RunAiAnalysis::subjectLabel()` (« AO 42/2026 »,
   sans bégayer quand la référence porte déjà « AO ») entre dans le titre de la notification, le
   corps la cite (`ai.background.notify_done_body_sujet`), le bouton « Ouvrir » mène à SA fiche —
   et une fiche de VEILLE mène à la fiche de veille, plus au 404 de la ressource des AO. La
   bannière (widget) écrit la même chose.
7. **Concurrents** — tri par défaut « AO soumissionnés » décroissant (puis nom), filtre
   « ≥ 2 rencontres » (`has('results', '>=', 2)`). Le drapeau « vu » reste manuel — décision du
   gérant.

## La recette (tests)

- `AffichageDenseAoTest` (étendu) — plus d'`overflow-wrap: anywhere`, troncature présente
  (`ellipsis` / `nowrap` / `max-width`), la densité cible les éléments peints, le 60 vh est là,
  UN SEUL `overflow: hidden` (la troncature — jamais une barre d'actions), `buyer_name` sans wrap
  + `limit(45)` sur les trois tables ;
- `FrictionsUiTest` (nouveau) — §B (notes humaines / texte intégral / objet entier), §D2 (colonnes
  masquées côté Veille seulement, filtre déplacé et opérant), §D3 (trois entrées, aucun doublon),
  §D4 (badge Famille, nature comprise), §D5 (section résultat conditionnelle), §D6 (le libellé
  nomme l'AO), §D7 (tri + filtre des concurrents) ;
- `AnalyseCompleteEtoileTest` (nouveau) — §C1 (analyse riche → `decision_aid` complet), §C2
  (projection sans concurrent inventé, cible ≥ plancher, base du plancher affichée), non-étoilé =
  rien, `edited` et analyse riche survivent, base vide tient, la fiche montre tout ;
- `EvenementsPortailTest` (ajusté) — rectificatif : non étoilé silencieux (l'événement reste écrit
  sur la fiche), étoilé notifié, parcours notifié, sourdine prime ;
- non-régression : suites Veille, AI, Ui, Tenders — au vert, plus la suite complète.

## Déploiement

Archive delta v3.28.0 → v3.29.0, **sans migration**. À pousser en parallèle : `moissonneur.js`
(dossier complet des étoilés dans le champ `dossier` de l'ingestion) — les deux ensemble.
