# Rapport — v3.16.0 (la page des AO se lit d'un coup d'œil, et l'IA projette le dépôt)

**Version du socle : se livre aux DEUX installations, à l'identique, par le même tarball.
Une migration additive et idempotente (`tenders` : deux colonnes nullable). Aucune dépendance
nouvelle, aucune suppression, aucun renommage.**

Deux choses sur le même module. La page de liste des AO est refaite pour se lire d'un coup d'œil.
Et — le cœur du lot — chaque AO **encore en préparation** reçoit une **projection du dépôt** :
à partir de l'historique réel, qui va soumissionner, à quel prix, et le meilleur prix pour gagner.

## 1. La page « Appels d'offres »

### Le bandeau : 4 tuiles → 6, sur trois colonnes

Deux tuiles étaient faibles et sont réparées :

- **« Écart moyen / offre gagnante » quitte le bandeau.** Elle affichait `0,0 %` dès qu'aucun AO
  perdu ne portait à la fois notre offre et l'offre gagnante — une tuile qui ne dit rien occupe la
  place d'une qui dirait quelque chose. `averageOfferGap()` **reste** : le PDF d'analyse globale
  (v3.15.0 §4) s'en sert.
- **« Montant en jeu »** listait les comptes par étape dans son sous-titre, mélangeant un montant
  et des effectifs. Le sous-titre dit désormais ce qui se joue : *N AO en préparation · prochain
  dépôt le JJ/MM*.

Les six tuiles : **AO suivis** (« N en cours · M tranchés »), **Taux de réussite** (« X gagnés /
Y aboutis »), **CA gagné** *(nouveau)*, **En jeu (pipeline)**, **Cautions bloquées**,
**Concurrents rencontrés** *(nouveau)* — « le + fréquent : NOM (n×) · le + gagnant : NOM (k) ».

Trois méthodes nouvelles, toutes dans les services (jamais dans le widget) :
`TenderPipelineService::wonRevenue()` (somme de **notre part** des offres retenues — doctrine
v3.3.1 §7), `::nextDepositDate()`, `::counts()`, et `CompetitorInsightService::leaderboard()`.

### Le tableau : trois colonnes qui n'ajoutent aucune donnée

Elles exploitent celle qui dormait déjà en base :

| Colonne | Ce qu'elle montre |
|---|---|
| **Notre position** | Retenue (vert) · Écartée + étape (rouge) · « à venir » tant que non tranché |
| **Résultat / vainqueur** | le `winner_name` en gras + **notre écart face à lui** — vert si nous étions moins chers, rouge sinon |
| **Concurrence** | « n plis · moins-disant NOM MONTANT » |

L'objet tient sur **une seule ligne** (lignes plus basses, page plus dense) et porte un lien 🔗
vers la consultation sur marchespublics.gov.ma quand `pmmpLink()` la connaît. `TenderStatsWidget`
en pied de page est intact, et l'action **Fiche PDF** de la v3.15.0 reste en bout de ligne.

## 2. La projection du dépôt

### Les deux gardes

**L'IA raisonne sur des faits, elle n'invente rien.** La projection se construit en deux temps.

**Premier temps — le socle déterministe, aucune IA** (`App\Services\Tenders\TenderProjectionService`) :

- les **concurrents candidats**, dans cet ordre de pertinence : ceux déjà rencontrés chez le
  **même acheteur**, puis ceux du **même type d'objet** (familles par mots-clés : communication,
  formation, audiovisuel, événementiel, étude, impression), puis les plus fréquents — chacun avec
  ses statistiques réelles via `CompetitorInsightService::competitorStats()` ;
- le **prix attendu** de chacun : estimation de CET AO × (1 + son écart moyen vs estimation).
  Une fourchette, pas une certitude — `null` quand l'écart moyen est inconnu ;
- **notre plancher de marge** : les coûts du bordereau ÷ (1 − marge cible) — c'est le
  `advised_price` que `TenderBordereauService::margin()` calcule déjà. Sans bordereau chiffré,
  repli sur l'estimation diminuée de la marge cible, **et le libellé dit que la marge n'est alors
  pas vérifiable** ;
- le **profil de l'acheteur** : sur ses AO tranchés portant au moins deux offres chiffrées,
  l'attributaire était-il le moins-disant, ou retenu malgré une offre plus chère (scoring) ?

**Second temps — le narratif IA**, à qui l'on passe ce contexte **déjà chiffré**, jamais la base
brute, avec une consigne stricte. Le modèle renvoie scénarios, soumissionnaires attendus, prix
conseillé, confiance et prudences.

**La consigne ne suffit pas, et n'est pas censée suffire.** Au retour, `recaler()` réimpose les
faits :

1. tout soumissionnaire dont le nom n'est **pas** dans les candidats fournis est **écarté** ;
2. le prix attendu d'un candidat est **toujours celui du socle**, jamais celui du modèle ;
3. `recommended_price.floor` est **imposé par le socle**, et `target` est **relevé au plancher**
   s'il passait dessous — la justification le dit alors en toutes lettres ;
4. les candidats que le modèle a passés sous silence **restent listés** : ils sont un fait.

### La plomberie

`AiFeature::TenderProjection` (plafond `ai.max_tokens.tender_projection`, défaut 4096 — la sortie
est courte) et `AiAnalysisKind::TenderProjection`, routés par l'infra de fond existante.

⚠️ Une projection **ne lit aucune pièce**. `AiAnalysisKind::exigeDesPieces()` distingue donc les
analyses qui ont besoin de documents de celle qui n'en a pas : sans cela, le dispatcher aurait
refusé de mettre le travail en file, et le Job aurait échoué faute de documents. Pour toutes les
autres analyses, l'absence de pièce reste un échec — rien ne change.

**Aucun appel IA dans la requête web** : la leçon de la v3.14.1 tient. L'action met en file, le Job
appelle le service, écrit `ai_projection` + `ai_projection_at`, et notifie.

### Où ça s'affiche

- **Action** « Projeter le dépôt (IA) » (menu Analyse IA, et menu de ligne), visible **seulement**
  tant que l'AO est en préparation, gated `ai.use` + `update` + fonction active. Le libellé devient
  « Recalculer la projection » quand une projection existe déjà.
- **Section infolist** « Projection IA — avant dépôt », visible si la projection existe **et** que
  l'AO n'est pas tranché : scénarios, tableau des soumissionnaires attendus (concurrent · prix
  attendu · base historique), cartouche plancher / cible / plafond + justification, et le bandeau
  « estimation à valider — la décision de prix reste au gérant ».
- **Fiche PDF** : bloc « Projection avant dépôt » **avant** les Notes, imprimé uniquement quand
  l'AO est avant dépôt. Une fois tranché, c'est le bloc « Résultat & concurrence » de la v3.15.0
  qui prend le relais — **jamais les deux**.

## 3. Recette

Suite complète verte, `pint`, `npm run build`. Couverture ajoutée :

- `tests/Feature/Tenders/TenderPipelineIndicatorsTest.php` — CA gagné en « notre part », prochaine
  date de dépôt (future, AO en cours seulement), comptes du bandeau, palmarès des concurrents, et
  le cas base vide ;
- `tests/Feature/Tenders/TendersTableColumnsTest.php` — les trois colonnes, le signe de l'écart au
  vainqueur, le lien du portail présent ou absent ;
- `tests/Feature/Tenders/TenderProjectionTest.php` — migration rejouable, provenance des candidats
  (acheteur → objet → fréquence) et leur ordre, prix attendu = estimation × écart moyen, plancher
  depuis le bordereau puis depuis l'estimation, profil de l'acheteur, **et les deux gardes** :
  un concurrent inventé est écarté, un prix conseillé sous le plancher est relevé ;
- `tests/Feature/AI/TenderProjectionBackgroundTest.php` — mise en file sans pièce (et refus
  maintenu pour les autres analyses), aucun appel HTTP depuis la requête web, le Job écrit la
  projection recalée, **sans historique le modèle n'est pas appelé du tout**, une fonction
  désactivée échoue avec une raison, et l'action comme la section disparaissent après le dépôt ;
- `tests/Feature/Pdf/TenderPdfExportsTest.php` — la Fiche PDF montre la projection avant dépôt et
  le résultat après, jamais les deux.

Deux corrections d'appui trouvées en chemin :

- `having('results_count', '>', 0)` sur un alias est **refusé par SQLite** (« HAVING clause on a
  non-aggregate query »). Remplacé par `whereHas('results')`, qui tient sur les deux moteurs.
- La garde « aucun lien PDF ne s'ouvre dans un onglet neuf » ne reconnaissait que les deux API
  Filament. Un `target="_blank"` écrit à la main dans une colonne HTML l'aurait contournée sans le
  vouloir : elle le reconnaît désormais aussi.

Non-régression vérifiée : note d'analyse IA, aide à la décision GO/NO-GO et les trois PDF de la
v3.15.0 répondent toujours.

## 4. Livraison

```bash
git diff --name-only --diff-filter=ACMR v3.15.0 v3.16.0
```

**Les deux installations reçoivent le même paquet.** La migration est additive et idempotente :
`php artisan migrate --force` est sûr des deux côtés — y compris sur une base sans aucun AO, où le
bandeau affiche des zéros et où une projection lancée se déclare « historique insuffisant ».

1. **Sauvegarde phpMyAdmin de la base** (avant tout) ;
2. extraction du paquet ;
3. `php artisan migrate --force` ;
4. `php artisan optimize:clear`.

La fonction IA « Projection du dépôt d'un AO » est active par défaut (comme les autres) et se
désactive dans les Réglages IA ; sans clé API, l'action ne s'affiche simplement pas.

## 5. Fichiers

**Ajoutés** — `database/migrations/2026_08_27_100000_add_ai_projection_to_tenders_table.php`,
`app/Services/Tenders/TenderProjectionService.php`,
`app/Services/AI/TenderProjectionAiService.php`,
`tests/Feature/Tenders/TenderPipelineIndicatorsTest.php`,
`tests/Feature/Tenders/TendersTableColumnsTest.php`,
`tests/Feature/Tenders/TenderProjectionTest.php`,
`tests/Feature/AI/TenderProjectionBackgroundTest.php`.

**Modifiés** — `app/Models/Tender.php`, `app/Enums/AiFeature.php`, `app/Enums/AiAnalysisKind.php`,
`app/Services/AI/AiManager.php`, `app/Services/AI/AiAnalysisDispatcher.php`,
`app/Jobs/RunAiAnalysis.php`, `app/Services/Tenders/TenderPipelineService.php`,
`app/Services/Tenders/CompetitorInsightService.php`,
`app/Filament/Admin/Resources/Tenders/Widgets/TenderPipelineWidget.php`,
`app/Filament/Admin/Resources/Tenders/Tables/TendersTable.php`,
`app/Filament/Admin/Resources/Tenders/Actions/TenderActions.php`,
`app/Filament/Admin/Resources/Tenders/Schemas/TenderInfolist.php`,
`app/Http/Controllers/TenderFichePdfController.php`,
`resources/views/pdf/tender-fiche.blade.php`, `lang/fr/ai.php`,
`tests/Feature/Documents/LecteurDeDocumentsTest.php`,
`tests/Feature/Pdf/TenderPdfExportsTest.php`, `CHANGELOG.md`.

**Aucune suppression, aucun renommage.**
