# Rapport — v3.21.0 (la concurrence lue depuis le portail)

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

## Le constat

Le portail publie, **sans compte**, pour chaque consultation, la page « Suivi des travaux de la
commission » : le tableau de **tous** les soumissionnaires, leur sort enveloppe par enveloppe, et le
montant de celui qui reste. C'est la seule source publique qui donne, d'un coup, les noms de nos
concurrents, leurs prix et l'étape où chacun est tombé.

La moissonneuse la relevait déjà et la déversait dans le champ `suivi` de `POST /api/veille/ingest`
(v3.18.0). Mais l'application n'en faisait **rien de structuré** : elle la joignait au texte confié
à l'IA, et c'était tout. La page **Concurrents** restait vide, et l'estimation de prix (v3.16.0)
n'avait aucun prix réel à se mettre sous la dent — alors que le tableau était là, dans la base, en
texte brut.

Or ce tableau n'a besoin d'aucune intelligence : il se **découpe**.

## §1 — `App\Services\Tenders\SuiviCommissionParser`

Un parseur **déterministe**, sans IA, sans réseau, sans dépendance. Il prend le texte rendu de la
page et rend, pour chaque soumissionnaire : son nom, son sort par enveloppe, son montant, son étape
d'élimination, et les compteurs de l'AO.

Les règles, et rien de plus :

| Ce qui est lu | Ce qui en est déduit |
|---|---|
| « Admissible » / « Écartée » / « Fermée » par enveloppe | `elimination_stage` = la **première** enveloppe où l'entreprise est écartée |
| « Fermée » | une enveloppe **non ouverte** — pas une élimination : la précédente avait tranché |
| Admissible aux **trois** enveloppes | `retained` |
| « Avant correction » / « Après correction » | l'**après** prime ; c'est le montant qui engage |
| Les admis à la financière qui ont un montant | `winner_name` + `winning_offer` = le **moins-disant**, sauf ligne marquée « retenu » / « attributaire », qui l'emporte |
| Le nombre de lignes | `total_bidders` ; `rejected_admin` / `_tech` / `_fin` par étape |

Il est **tolérant** parce qu'un texte rendu par un navigateur n'est jamais régulier : il lit le
tableau qu'il soit collé en barres verticales, en tabulations, une cellule par ligne, ou tout à plat
sur une seule ligne ; casse, accents, espaces insécables et fines lui sont indifférents ; « - »
signifie vide ; un doublon de ligne ne compte qu'une fois ; un texte sans tableau rend une liste
vide **et aucune erreur**.

Un **repli** lit l'autre format que publient certains acheteurs, l'**extrait de PV** en prose
(« concurrents évincés : … », « admissibles sans réserve : … », « admissibles avec réserve : … »).
Un PV ne dit pas à quelle enveloppe l'évincé est tombé : l'étape reste donc **nulle**. On préfère ne
rien dire à inventer une étape qui fausserait ensuite les statistiques du concurrent.

Le choix entre les deux formats est mécanique : une vraie ligne de tableau porte au moins **deux**
enveloppes ou un montant. Faute de quoi, le texte est de la prose où un mot de verdict traîne — et
c'est le repli qui prend la main. Un « admissible » perdu dans une phrase ne fabrique donc pas un
concurrent fantôme.

## §2 — Le branchement dans l'ingestion

`VeilleImportService::appliquerSuiviCommission()` — appelée par `ingererConsultation()` dès que
`suivi` est fourni, **avant** toute mise en file : le tableau se lit sans modèle, donc la fiche
porte ses concurrents et leurs prix tout de suite, y compris sur une installation **sans IA**.

Ce qu'elle remplit :

- les **concurrents** (`competitors`), dédoublonnés par nom — et par ICE le jour où le portail en
  publiera —, via le `resolveOrCreateCompetitor()` déjà en place ;
- leur **ligne de résultat** (`tender_results`) sur cet AO : montant, retenu / écarté, étape ;
- les **agrégats** de la fiche — `total_bidders`, `winner_name`, `winning_offer`,
  `rejected_admin/tech/fin` — **seulement là où elle ne disait rien**.

Trois garanties, tenues par test :

- **On complète, on n'écrase pas.** Le montant n'est écrit que s'il manquait. Le verdict n'est posé
  que sur une ligne encore **muette** : dès qu'une ligne est retenue, écartée, ou porte une étape,
  c'est un jugement humain (ou une lecture antérieure) et il prime. Un agrégat déjà posé — **zéro
  compris**, car zéro affirme que personne n'a été écarté — n'est pas réécrit.
- **Idempotent.** La moissonneuse repasse sur les mêmes consultations : le second passage ne crée
  aucun concurrent, aucune ligne, et n'écrit rien. La réponse de l'endpoint le dit
  (`"concurrents": 0`).
- **Hors indicateurs.** La fiche reste `participation = veille`. Taux de réussite, CA gagné,
  cautions bloquées, montant en jeu et compteurs sont **identiques** avant et après enrichissement.

Marche pour un AO **nouveau** comme pour un AO **déjà en base** : le dédoublonnage sur
`ref_consultation` retrouve la fiche, et la concurrence vient s'y ajouter.

### Deux portes, la même mécanique

- `POST /api/veille/ingest` transmet `suivi` au service et rend `concurrents` (le nombre de lignes
  créées ou complétées) dans sa réponse JSON.
- L'action **« Ajouter depuis un lien portail »** gagne un champ « Suivi des travaux de la
  commission (facultatif) », **toujours visible** — contrairement au champ de contenu, réservé aux
  installations qui ont l'IA : ce tableau-ci se lit sans modèle. Un exploitant peut donc remplir la
  concurrence d'un AO à la main, sans moissonneuse et sans clé d'API.

## §3 — Ce que ça nourrit

Rien à changer : la concurrence est **déjà** lue sans distinction de participation. C'est la
doctrine posée en v3.17.0 (`Tender::scopeSoumissions()` ne s'applique **pas** à l'intelligence de
marché) ; la v3.21.0 se contente de la remplir. Les tests le prouvent plutôt que de le supposer :

- **`CompetitorInsightService`** — le face-à-face de l'AO montre les six offres et leurs étapes ;
  `competitorStats()` compte cet AO observé dans l'historique du concurrent, son taux de réussite et
  ses éliminations par étape.
- **`TenderProjectionService`** (l'estimation de prix, v3.16.0) — un concurrent rencontré chez le
  même acheteur **sur une veille** se présente comme candidat sur notre AO, avec son écart moyen
  vis-à-vis de l'estimation et son prix attendu.

## La recette

`tests/Feature/Veille/SuiviCommissionTest.php` (18 tests, 134 assertions) et un test ajouté à
`VeilleIngestApiTest`. Aucune donnée réelle : ni société, ni montant, ni référence d'une
installation.

- **Parseur** : le tableau de six soumissionnaires donne 1 retenu à 904 200,00, 1 écarté au
  **technique**, 4 à l'**administratif** ; `total_bidders` = 6, `rejected_admin` = 4,
  `rejected_tech` = 1 ; « après correction » l'emporte sur « avant » ; les quatre habillages du
  texte rendu donnent le **même** résultat ; casse et accents indifférents ; doublon compté une
  fois ; « retenu » désigne l'attributaire même s'il n'est pas le moins-disant ; sans montant, pas
  d'attributaire ; texte vide ou sans tableau → rien, sans erreur ; l'extrait de PV se lit, sans
  étape inventée.
- **Ingestion** : concurrents + agrégats remplis, statut passé à « retenu » ; **re-POST** du même
  suivi → aucun doublon, rien de réécrit ; une ligne saisie à la main (prix + verdict) **survit** à
  une lecture qui dit le contraire ; un agrégat déjà posé n'est pas touché, ce qui manque est
  complété ; un AO existant est enrichi sans être dupliqué ; **aucun job en file**.
- **Hors KPI** : les cinq indicateurs sont inchangés après enrichissement d'une veille.
- **Base vide (RUWAD)** : ingestion sans `suivi`, `suivi` vide ou illisible → 0 concurrent, aucune
  fiche polluée, aucune erreur. Aucune migration à jouer.
- **Non-régression** : suite complète verte, `VeilleIngestApiTest` (v3.18), `VeilleEnrichissementTest`
  (v3.19), `ParcoursAoTest` (v3.20), `VeilleScopingTest` et la projection v3.16 compris.

## À faire après déploiement

1. Rien à lancer : aucune migration, aucune commande de reprise.
2. Sur un AO de veille dont le résultat est connu, ouvrir « Ajouter depuis un lien portail », coller
   la page « Suivi des travaux de la commission » et vérifier que la concurrence apparaît.
3. Ouvrir **Concurrents** : les entreprises lues doivent y figurer avec leur historique.
4. Ouvrir un AO en préparation chez un acheteur déjà observé : les candidats et leur prix attendu
   doivent apparaître dans l'estimation.
5. Côté moissonneuse : rien à changer, elle envoie déjà `suivi`. Sa réponse porte désormais
   `concurrents` — à journaliser si c'est utile.
