# Rapport — v3.24.0 (dossier complet des AO étoilés, écart attributaire / estimation, fiche portail)

**Se livre aux DEUX installations, à l'identique. Aucune migration, aucune dépendance composer
nouvelle, aucune suppression de fichier.**

## Le constat

Trois manques, un seul lot.

**1. Un AO qu'on vise se prépare, et le socle n'en savait rien.** Pour une consultation étoilée, on
veut les mêmes cases qu'un AO déjà soumissionné : résumé du travail, bordereau des prix, délai
d'exécution, pièces demandées, modalités de paiement, résumé d'exécution. Ces informations ne vivent
que dans le **règlement de consultation** et le **cahier des prescriptions spéciales** — des scans,
du texte libre. Seule l'IA les structure. Mais faire lire tous les dossiers du royaume par un modèle
n'a aucun sens : c'est pour ça que la lecture est **réservée aux AO étoilés**, une poignée par mois.

**2. On avait les deux chiffres, on ne les rapprochait pas.** Depuis la v3.23.0, une fiche clôturée
porte l'**estimation** (lue dans l'avis) ET l'**attributaire avec son offre** (lu dans le PV). Leur
écart dit, marché par marché, de combien on gagne dans ce secteur — c'est le chiffre qui manquait
pour situer un prix. Calcul pur : aucune IA.

**3. Le panneau détaillé du portail restait noyé.** La moissonneuse le relève désormais pour chaque
AO — type d'annonce, catégorie, allotissement, lieu d'exécution, domaines d'activité, adresses de
retrait et de dépôt, qualifications, agréments — mais il se perdait au milieu d'un mur d'OCR.

## L'invariant du lot

> **L'IA ne tourne QUE sur les AO étoilés.** Aucune analyse n'est mise en file par la veille
> ordinaire, les AO clôturés ou l'historique. C'est ce qui rend la fonction tenable.

Et la règle du socle, inchangée : **on complète, on n'écrase pas**. Une case saisie à la main n'est
jamais remplacée par une lecture de dossier. L'IA propose, elle ne tranche ni les prix ni les
go/no-go.

---

## §A1 — `GET /api/veille/etoiles-a-approfondir`

Nouveau point d'entrée (`VeilleEtoilesAApprofondirController`, route nommée `api.veille.etoiles`).
La moissonneuse le lit AVANT de télécharger quoi que ce soit : télécharger le dossier complet de
toutes les consultations du royaume n'aurait aucun sens.

- **Mêmes garanties que l'ingestion** : fermée par défaut (**503** sans jeton configuré), **même
  jeton** (`services.veille_ingest.token`), **même throttle** (`veille-ingest`), **401** sinon. La
  comparaison de jeton vit désormais dans un trait partagé, `JetonDeMoissonneuse` — deux portes qui
  se refuseraient différemment seraient une porte de trop.
- **Aucune écriture** : c'est une lecture.
- **Ce qu'elle rend** : les AO `participation = veille` **étoilés** (`favori = true`) qui n'ont pas
  encore de dossier approfondi — c'est-à-dire dont `ai_analysis` ne porte pas la clé
  **`dossier_extrait_at`** (`DossierEtoileService::CLE_MARQUEUR`). Pour chacun :
  `tender_id`, `ref_consultation`, `org_acronyme`, `pmmp_url`, `reference`, `objet`, `date_limite`.
- Trié par **date limite la plus proche** (les fiches sans date en dernier), plafonné à **200**
  (paramètre `?limite=`).
- Étoiler un AO l'y fait entrer ; le dé-étoiler l'en sort ; une lecture de dossier l'en sort aussi.

## §A2 — L'ingestion accepte le champ `dossier`

`POST /api/veille/ingest` accepte **`dossier`** (texte OCR du dossier complet, borné au même plafond
que les autres champs).

- **Toujours rangé dans les `notes`**, sous `VeilleImportService::MARQUEUR_DOSSIER`
  (`===== DOSSIER =====`), **sans écraser** l'avis, le bloc source du portail ni une note manuelle.
  Gratuit : aucun modèle n'est appelé pour ranger un texte.
- **Extraction structurée SEULEMENT si l'AO est étoilé** et si la fonction IA est disponible : un
  run `AiAnalysisKind::VeilleDossier` part **en file** (jamais synchrone — leçon v3.14.1), rattaché
  à la fiche comme sujet.
- **AO non étoilé : aucun run.** La réponse le dit (`dossier_extraction = "non_etoile"`), et c'est
  la réponse ordinaire.
- La réponse de l'API porte **`dossier`** (booléen : le texte a-t-il été rangé ?) et
  **`dossier_extraction`** (`null` | `non_etoile` | `indisponible` | `file` | `echec`).

**Les notes savent maintenant porter plusieurs blocs.** `rangerAvisDansLesNotes()` et
`rangerDossierDansLesNotes()` passent par une mécanique commune qui décompose les notes en
préambule + blocs, réécrit chaque bloc à sa place, et conserve l'ordre du socle (préambule, avis,
dossier). Conséquence directe : re-déverser un avis **après** qu'un dossier a été rangé reste
idempotent — sans cela, le dossier rangé en dessous aurait fait passer chaque avis pour un avis
différent, et les notes auraient enflé à chaque passage.

## §A3 — Le schéma d'extraction étendu

`VeilleExtractionService::extraireDossierDuTexte()` : même fonction IA, même consigne système, même
refus d'une réponse illisible que l'extraction d'avis — mais une consigne de sortie propre au
dossier, qui **ajoute** aux champs existants :

```
resume_travail · delai_execution · mode_passation · bordereau[{designation, unite, quantite}]
documents_demandes[] · modalites_paiement · resume_execution
```

**Règle absolue inchangée** : n'extraire QUE ce qui est écrit, ne jamais inventer, ne jamais compléter
un bordereau par des postes « habituels » ni ajouter une pièce non demandée. Un champ absent reste
`null`, une liste inconnue reste `[]`.

Le nettoyage (`nettoyerDossier()`) ne garde que les champs connus — une clé inventée par le modèle
n'atteint jamais la base — et ramène le bordereau à la **forme canonique du socle**
(`designation` / `unit` / `quantity`) : le modèle répond en français parce que la consigne l'est,
mais `ai_analysis` n'a qu'une seule forme de bordereau, celle que `TenderBordereauService` sait
semer quand l'AO passe en préparation.

## §A2 (suite) — Ce que la lecture dépose sur la fiche

`DossierEtoileService::appliquer()` est le **seul** endroit où cette lecture touche la base.

- **Colonnes simples, si vides seulement** : `duration_label`, `mode_passation`, `caution_required`,
  `estimated_amount`.
  ⚠️ `mode_passation` porte un **défaut en base** (`marche`) : en pratique la colonne n'est presque
  jamais vide, et c'est le libellé écrit du dossier (`ai_analysis['mode_passation_texte']`) que la
  fiche montre. On ne force pas la colonne : « bon de commande » dispense de caution, et ce n'est
  pas à une lecture de dossier de trancher ça.
- **`ai_analysis`, clé par clé, si vide seulement** : `resume_travail`, `bordereau`,
  `documents_demandes`, `modalites_paiement`, `resume_execution`, plus `delai_execution` et
  `mode_passation_texte` pour l'affichage. Une analyse d'AO complète (Prompt 28) n'est jamais défaite
  par une lecture de veille.
- **Marqueur `dossier_extrait_at`**, posé **dans tous les cas** — même quand le dossier n'apprend
  rien. La question qu'il répond n'est pas « la fiche a-t-elle changé ? » mais « faut-il redemander
  ce dossier ? ». Sans lui, un dossier illisible serait re-téléchargé et re-payé à chaque passage.
- `ai_analysis_at` n'est posé qu'à la **première** lecture.

## §A4 — La section « Dossier de préparation »

Sur la fiche d'un AO (Veille comme nos offres) : résumé du travail, délai et mode de passation en
cartouches, **bordereau** en tableau (désignation / unité / quantité), **documents demandés** en
liste, **modalités de paiement**, **résumé d'exécution**, la date de lecture et un renvoi vers le
texte complet dans les notes. **Section absente** quand tout est vide.

---

## §B — L'écart attributaire / estimation

`EcartAoService` — **aucune IA, aucune colonne, aucun stockage**, tout se calcule à la volée.

- `attributaire_montant` = `winning_offer − estimated_amount` (DH) et `attributaire_pourcent` =
  le même rapporté à l'estimation. **Négatif = sous l'estimation.**
- `irada_pourcent` = notre offre face à la même estimation ; `irada_vs_attributaire` = ce qui nous
  séparait du retenu, en dirhams.
- **`null` proprement** dès qu'une donnée manque — et une estimation **absente ou à zéro** est la
  même chose : « l'acheteur n'a pas publié de montant ». Écrire « attribué 90 000 DH au-dessus de
  zéro » serait un chiffre juste et une information fausse.
- `anormalement_bas` : plus de **20 %** sous l'estimation. Un repère de lecture, pas un verdict.

**Page « AO clôturés »** : colonne **« Écart / estim. »** en badge — **vert** sous l'estimation,
**rouge** au-dessus, vide sans écart — **triable en base** (les fiches sans écart restent en fin de
liste dans les deux sens), avec la mention « anormalement basse ? » sous le seuil. Nouveau filtre
`FiltresAo::sousEstimation()` : « attribué sous l'estimation » / « attribué au-dessus ».

> ⚠️ Le tri porte `* 1.0` dans son expression SQL : **sur SQLite, une division de deux entiers est
> entière** — −10 % et +30 % vaudraient tous deux zéro et la colonne ne trierait rien. MySQL ne pose
> pas ce problème ; le socle doit trier pareil sur les deux.

**Fiche AO** : section « Écart attributaire / estimation » — le montant signé et son pourcentage,
l'estimation en rappel, et, quand nous avons soumissionné, « écart notre offre / estimation » et
« notre offre vs attributaire ». Absente tant que les deux chiffres ne sont pas connus.

---

## §C — La fiche portail, sur chaque AO et dans toutes les phases

`App\Support\FicheDetailPortail` lit le bloc **`===== FICHE DETAIL =====`** que la moissonneuse joint
aux notes et le rend en paires **libellé / valeur**, dans l'ordre du portail.

- **Déterministe** : aucune IA, aucune migration. Une ligne `Libellé : valeur` fait une paire ; une
  ligne qui n'en est pas une est ignorée.
- **Borné** : le bloc s'arrête à la ligne de séparateur suivante (`=====`) — l'avis et le dossier
  rangés en dessous ne débordent jamais dans la fiche.
- **Rien d'inventé** : le libellé doit porter une lettre, tenir en 80 caractères et ne contenir ni
  deux-points ni barre oblique (sans quoi une adresse web collée seule se ferait couper après
  « https »).
- **Section masquée** quand le bloc est absent — AO saisi à la main, ou pas encore moissonné.

La section est placée **au-dessus des notes libres**, sur la fiche de la Veille comme sur celle de
nos offres : la même fiche sert à toutes les phases (Veille, clôturés, ⭐ étoile, à préparer,
soumissionnés, marchés).

---

## Recette

`tests/Feature/Veille/DossierEtoileEtEcartTest.php` — 23 tests, aucune donnée réelle.

- Le point d'entrée est fermé par défaut (503), refuse un mauvais jeton (401) ; un AO étoilé y
  apparaît, le dé-étoiler l'en sort, un dossier lu l'en sort ; la masse n'y apparaît jamais.
- `dossier` sur un AO **étoilé** → texte dans les notes + run `VeilleDossier` en file, rattaché à la
  fiche. Sur un AO **non étoilé** → texte dans les notes, **zéro run**.
- Le dossier ne remplace ni l'avis ni une note manuelle ; l'ordre des blocs est stable ; deux
  déversements du même dossier ne l'empilent pas ; un avis re-déversé après un dossier reste
  idempotent.
- La lecture remplit les colonnes vides et `ai_analysis` ; une case saisie à la main survit ; le
  bordereau prend la forme canonique du socle ; le service ne garde que les champs connus ; le mode
  de passation n'est lu que lorsqu'il est écrit.
- Écart : 100 000 / 90 000 → **−10 000 DH (−10,0 %)**, vert ; 130 000 → **+30,0 %**, rouge ; sous
  −20 % signalé ; estimation ou attributaire absent (ou estimation à zéro) → écart vide, sans
  erreur ; nos écarts affichés quand `our_offer` existe ; colonne triable et filtre « sous
  l'estimation » sur la page des clôturés.
- Fiche portail : le bloc se lit en paires clé / valeur sans déborder sur l'avis ; son absence n'est
  pas une erreur ; la fiche d'un AO montre les trois sections quand il y a de quoi, et aucune quand
  il n'y a rien.

## À vérifier après déploiement

1. Le `.env` porte déjà `VEILLE_INGEST_TOKEN` : le nouveau point d'entrée s'ouvre avec **le même**
   jeton, il n'y a rien à ajouter.
2. `GET https://<installation>/api/veille/etoiles-a-approfondir` avec l'en-tête
   `Authorization: Bearer <jeton>` doit rendre la shortlist (`{"ok":true,"total":…}`).
3. Étoiler un AO, laisser la moissonneuse déverser son `dossier`, vérifier que la section
   « Dossier de préparation » apparaît sur sa fiche après passage du worker de file.
4. Sur « AO clôturés », la colonne « Écart / estim. » doit se remplir sur les AO qui portent à la
   fois une estimation et un montant retenu, et se trier dans les deux sens.

**Aucune migration à passer.** `php artisan optimize:clear` après extraction, comme d'habitude.
