# Rapport — v3.18.0 (la moissonneuse alimente la Veille : `POST /api/veille/ingest`)

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

## Le constat

La page **Veille (AO non soumissionnés)** se remplit à la main, ou par collage d'un lien + du
contenu de la page — le portail ne se lit pas côté serveur (v3.17.2), c'est l'IA qui lit le **texte
rendu** de la consultation.

Une **moissonneuse** tourne désormais sur un petit serveur : elle ouvre le portail dans un vrai
navigateur, lit les consultations ouvertes, et récupère leur texte rendu. Exactement le même
contenu qu'un collage manuel.

Il manquait la **porte d'entrée**.

## §1 — La mécanique de la v3.17.2, extraite là où elle sert deux fois

`VeilleImportService::ingererConsultation()` porte désormais ce que l'action Filament « Ajouter
depuis un lien portail » faisait dans son `->action()` :

1. la fiche de veille est **créée ou complétée tout de suite** (participation = veille,
   identifiants du portail, lien reconstruit), avec **dédoublonnage strict sur
   `ref_consultation`** et la règle « on complète, on n'écrase pas » — y compris la référence
   provisoire `PMMP-<id>`, qui reste remplaçable parce que personne ne l'a saisie ;
2. s'il y a un **contenu** et que la fonction IA est disponible, une `VeilleExtraction` part **en
   file** sur ce texte, rattachée au `ref_consultation` : son brouillon complétera **la même**
   fiche à la validation ;
3. elle rend `['statut' => 'cree'|'complete'|'connu', 'tender_id' => …, 'ref_consultation' => …]`,
   plus de quoi dire à l'écran ce qu'est devenu le contenu.

**L'action Filament emprunte maintenant ce chemin**, et rien d'autre. Aucune régression pour
l'exploitant : mêmes fiches, mêmes brouillons, même dédoublonnage — seuls les messages à l'écran
sont restés côté Filament, parce qu'ils n'ont de sens que là.

## §2 — La porte : `POST /api/veille/ingest`

Un contrôleur mince (`App\Http\Controllers\Api\VeilleIngestController`), et rien de plus :

- **Jeton.** Le `Bearer` de l'en-tête `Authorization` est comparé en `hash_equals` à
  `config('services.veille_ingest.token')`. Jeton non configuré → **503**
  `{"ok":false,"erreur":"endpoint desactive"}` : la porte est **fermée par défaut**. Jeton absent
  ou faux → **401** `{"ok":false,"erreur":"non autorise"}`.
- **Validation.** `ref_consultation` et `org_acronyme` requis ; `pmmp_url` (URL), `objet` /
  `source_meta` (libellé de secours), `contenu` et `suivi` facultatifs, bornés au plafond des
  pièces (`services.ai.max_document_chars`). Corps invalide → **422** JSON détaillé.
- **Ingestion.** `suivi` est **joint** au `contenu` : les montants des concurrents font partie du
  texte à lire. L'appel crée/complète la fiche et **met en file** l'IA — il n'attend **jamais** le
  modèle (leçon v3.14.1), et la moissonneuse peut enchaîner.
- **Réponse.** `{"ok":true,"statut":"cree|complete|connu","ref_consultation":"…","tender_id":…,
  "extraction":"file|indisponible|echec|null"}` — **201** à la création, **200** sinon.
- **Toujours du JSON.** Toute exception est capturée, journalisée, et rendue en `500` propre. Ni
  jeton, ni trace, ni page HTML dans la réponse.

La route vit dans `routes/api.php`, chargé par `bootstrap/app.php` sous le groupe **`api`** :
stateless, sans session, sans cookie, **sans Sanctum** — qui n'est pas installé et ne le sera pas.
L'URI est en outre inscrite dans la liste `except` de la protection CSRF, de sorte que l'endpoint
reste joignable même si la route rejoignait un jour le groupe `web`. Fréquence bornée à
**60 appels/minute et par adresse** (`throttle:veille-ingest`), refus compris : c'est l'échec qu'on
veut rendre coûteux.

## §3 — Le jeton, et ce qu'il faut en faire

**À faire une fois par installation, à la main, sur le serveur :**

```bash
php -r "echo bin2hex(random_bytes(32)), PHP_EOL;"
```

La valeur obtenue se pose à **deux** endroits, et nulle part ailleurs :

1. le `.env` de l'application — `VEILLE_INGEST_TOKEN=<la valeur>` — puis
   `php artisan config:clear` ;
2. la configuration de la moissonneuse, sur le VPS.

⚠️ **Un jeton par installation.** IRADA et RUWAD n'ont pas le même, et il ne se partage pas. Il
n'est jamais committé : `.env.example` ne porte que la ligne vide et son commentaire.

Tant que le jeton n'est pas posé, l'endpoint répond 503 et n'écrit rien : une installation sans
moissonneuse n'expose aucune surface.

**Ce que la porte accepte, et seulement cela** : des données publiques de veille. Elle ne touche ni
la trésorerie, ni les AO soumissionnés, ni les indicateurs ; elle ne crée que des fiches
`participation = veille` et des **brouillons à valider**. Le logiciel propose, l'exploitant valide.

## La recette

`tests/Feature/Veille/VeilleIngestApiTest.php` — 20 tests :

- **auth** : sans jeton → 401, mauvais jeton → 401, jeton non configuré (ou fait d'espaces) → 503,
  bon jeton → 201/200 ; un appel refusé n'écrit rien ;
- **création** : une fiche de veille, `statut:"cree"`, `tender_id` présent, lien du portail
  reconstruit, objet et acheteur **vides** (jamais inventés) ;
- **dédoublonnage** : re-POST du même `ref_consultation` → `connu`/`complete`, **une seule fiche** ;
  un objet corrigé à la main survit au passage suivant de la moissonneuse ;
- **contenu → IA par la file** : `RunAiAnalysis` poussé, aucun appel HTTP, run rattaché au
  `ref_consultation`, sans utilisateur ; `suivi` joint au texte ; sans fonction IA, la fiche existe
  et le contenu est ignoré **sans erreur** ;
- **corps invalide** : 422 JSON détaillé, URL non conforme refusée, contenu hors plafond refusé ;
- **KPI intouchés** : taux de réussite, CA gagné et comptes du bandeau inchangés ; ni caution ni
  mouvement de trésorerie créés ;
- **hors CSRF et hors session** : URI dans la liste `except`, route dans le groupe `api` et pas
  `web`, throttle en place ;
- **JSON only** : une exception interne rend `{"ok":false,"erreur":"erreur interne"}` en 500, sans
  laisser fuir le message ni le jeton ;
- **base vide (RUWAD)** : l'endpoint répond 503/401 sans planter, aucune migration requise ;
- **aucune dépendance d'API** : Sanctum, Passport, JWT, Jetstream absents de `composer.json`.

**Non-régression** : `VeilleImportTest` et `VeilleIaTest` (les quatre alimentations, l'action
« Ajouter depuis un lien portail », le brouillon qui complète la fiche du lien sans doublon,
l'interprétation, la projection, les liens) passent inchangés — c'est la preuve que la
factorisation du §1 n'a rien déplacé du comportement.

## Après déploiement

1. `php artisan config:clear` (le jeton est lu depuis le `.env`).
2. Vérifier la porte **fermée** avant de poser le jeton :
   `curl -i -X POST https://<app>/api/veille/ingest` → **503**.
3. Poser le jeton, `config:clear`, puis vérifier le refus :
   `curl -i -X POST -H 'Authorization: Bearer faux' https://<app>/api/veille/ingest` → **401**.
4. Un premier déversement à blanc :
   ```bash
   curl -i -X POST https://<app>/api/veille/ingest \
     -H "Authorization: Bearer $VEILLE_INGEST_TOKEN" \
     -H 'Content-Type: application/json' \
     -d '{"ref_consultation":"000000","org_acronyme":"tst"}'
   ```
   → **201** et une fiche « PMMP-000000 » dans la Veille, à supprimer ensuite.
5. Le worker de file doit tourner (`queue:work`) pour que les extractions se lisent : sans lui, les
   fiches se créent quand même, les brouillons attendent.
