# Rapport d'exécution — v3.32.2 (l'ingest vise la bonne fiche, les files ne tournent plus en rond)

**Correctif de ciblage de l'ingestion et des files d'attente. Aucune migration, aucune dépendance
composer, aucun changement d'affichage (la v3.32.1 validée au pixel n'est pas touchée). Se livre
aux deux installations, à l'identique — tient sur une base vide.**

---

## Où était le défaut

### 1. L'ingest ne cherchait la fiche existante que dans la veille (volet A)

`VeilleImportService::trouver()` — le SEUL endroit qui décide « fiche connue ou fiche à créer » —
cherchait par `ref_consultation` avec le scope `->veille()` :

```php
Tender::query()->veille()->where('ref_consultation', $ref)->first();
```

La fiche 51/2024/DAAJ du gérant (tender **id 1**, `ref_consultation = 855776`) est
`participation = soumission` : la recherche ne la voyait pas, `importer()` concluait « fiche
inconnue » et créait un **squelette** `participation = veille`, réf provisoire « PMMP-855776 »,
objet et acheteur vides — c'est lui (id 651) qui recevait la `fiche_ia`, le bordereau, les
familles. Idem 912297 → 652 et 941040 → 653. Une erreur qui *réussit* : la réponse
`{"ok":true,"statut":"connu","tender_id":651}` avait l'air parfaitement normale.

**Correctif** : la recherche par `ref_consultation` couvre désormais **toutes** les fiches, quelle
que soit leur `participation` (veille, étoilée, parcours, clôturée, masquée). Quand plusieurs
fiches partagent le ref, une **prioritaire** répond, dans un ordre stable : la fiche du parcours
avant la veille, l'`org_acronyme` confirmé avant les autres, une vraie référence avant un
« PMMP-… », la plus ancienne enfin. **Créer une fiche quand le ref existe déjà est devenu
impossible** : la création ne se produit que si `trouver()` ne rend rien.

Le repli par `reference` (+ acheteur), utilisé quand il n'y a pas d'identifiant portail (CSV,
saisie), reste volontairement scopé sur la veille : une référence seule (« 12/2024 ») se répète
d'un acheteur à l'autre, et un CSV approximatif ne doit pas pouvoir écrire sur une de nos offres.

Deux gardes accompagnent l'ouverture aux fiches du parcours :

- `completer()` ne bascule plus jamais le **statut** d'une soumission : sur une de nos offres,
  « retenu » veut dire « NOUS avons gagné » — un attributaire observé remplit `winner_name` si le
  champ était vide, mais le verdict reste au gérant (le comportement historique est conservé sur
  les fiches de veille) ;
- `corrigerDepuisFicheDetail()` (l'unique écriture qui écrase) ne remplace une valeur pleine dont
  personne ne connaît l'auteur **que sur une fiche de veille** — sur une fiche du parcours, dans
  le doute, on n'écrase pas (les champs vides, les blocs source et les valeurs que le socle avait
  lui-même posées restent corrigeables).

### 2. La doctrine « toutes les fiches au même niveau » (volet A3)

Quand plusieurs fiches partagent un ref (la fiche du parcours + une observation), les champs IA et
le dossier livrés s'écrivent désormais sur **toutes** (`propagerAuxJumelles()`) : blocs de notes
(avis, dossier), `famille_ia`, contrat `fiche_ia` / `familles_ia` / `mode_passation_ia`. Chaque
écriture garde ses règles — les notes se complètent sous leur séparateur, les clés IA ne touchent
qu'à elles-mêmes — donc les montants, statuts, cautions, notes manuelles et résultats saisis par
le gérant ne bougent jamais.

### 3. Les files re-servaient ce qui était déjà livré (volet B)

**`a-approfondir`** — la file testait `fiche_ia` sur la fiche seule. La vraie fiche (id 1) ne
recevant jamais sa photo (elle atterrissait sur le squelette 651), le ref 855776 était re-servi à
chaque passage — 00h, 03h, 06h identiques — et la moissonneuse retéléchargeait les mêmes DCE
toutes les 3 h pendant que le reste de la file attendait. Le correctif du volet A supprime la
cause ; la file ajoute deux ceintures : un ref est **exclu dès qu'UNE fiche de ce ref porte
`fiche_ia`** (le marqueur testé est exactement celui que l'ingest pose :
`FicheIaService::CLE_FICHE`, horodaté `CLE_FICHE_AT` — vérifié), et un ref n'est **servi qu'une
fois** par réponse (dédoublonnage, la prioritaire répond).

**`a-classifier`** — la stagnation (197 → 196 → 195 alors que ~196 classifications « réussissent »
à chaque passe) : trois défauts de la file, corrigés tous les trois. La cause principale est une
**clé**, pas un ciblage :

1. **la file ne testait que la clé v3.31 `famille_ia`** (slug simple), jamais `familles_ia`
   (les familles multiples du contrat v3.32). Une fiche dont la lecture de dossier avait livré ses
   familles — la réponse d'ingest dit `ok:true`, la moissonneuse journalise un succès — restait
   « à classifier » pour toujours : l'écriture réussit, la file ne la voit pas. C'est exactement le
   « bug silencieux » pressenti dans le prompt ;
2. **la file servait des fiches sans `ref_consultation`** : la porte d'ingestion exige ce champ —
   la classification de ces fiches ne pouvait JAMAIS atterrir. Classifiées en pure perte à chaque
   passage ;
3. **aucun dédoublonnage par ref** : avec des refs en double (les squelettes), la livraison
   atterrissait sur une fiche du ref, les autres restaient dans la file.

La file exclut désormais un ref dès qu'**une** fiche de ce ref porte `famille_ia` **ou**
`familles_ia`, ne sert que les fiches livrables (`ref_consultation` non nul) et dédoublonne par
ref. À noter, hors périmètre de l'app : un slug que le référentiel actif du gérant ne connaît pas
reste refusé en silence (comportement v3.31 voulu) avec `famille_ia:false` dans la réponse — si le
compteur ne tombe pas à zéro après déploiement, comparer les slugs envoyés par le VPS aux slugs
**actifs** de `familles_objet` (la réponse d'ingest le dit, la moissonneuse peut le journaliser).

### 4. Réparer l'existant (volet C)

`php artisan veille:fusionner-squelettes` — idempotente, bilan affiché
(« N squelette(s) fusionné(s) / M supprimé(s) / K sans jumeau (laissés) ») :

- **squelette** = fiche de veille avec `ref_consultation` dont la référence est restée le
  provisoire « PMMP-<ref> », ou dont l'objet ET l'acheteur sont vides — jamais une fiche où une
  saisie manuelle est marquée ;
- quand une **vraie fiche** du même ref existe (une fiche qui n'est pas elle-même un squelette ;
  le parcours d'abord, la plus ancienne ensuite), elle reçoit les champs IA (`fiche_ia`,
  `familles_ia`, `famille_ia`, mode de passation, horodatages — uniquement là où elle n'a rien),
  les blocs de notes (avis, dossier — par la même mécanique que l'ingestion, sans écraser), les
  lignes de concurrence que le squelette était seul à porter, et l'étoile si le squelette la
  portait ;
- le squelette est **supprimé** (suppression douce, auditée) ; un squelette **sans jumeau** est
  laissé — c'est une fiche de veille ordinaire, jeune ou incomplète ;
- couvre les cas connus (651 → 1, 652, 653) et balaie toute la base ; se rejoue sans effet.

## Ce qui n'a pas bougé

- La validation d'entrée de `POST /api/veille/ingest` (dont le plafond de caractères — le 422 sur
  1027754 est réglé côté moissonneuse) ;
- l'affichage v3.32.1 (aucun fichier Filament/vue touché) ;
- aucune migration : tous les marqueurs vivent dans le JSON `ai_analysis` ;
- aucune dépendance composer.

## Tests (le cœur du lot)

`tests/Feature/Veille/CiblageIngestV3322Test.php` — 9 tests, 44 assertions, tous de bout en bout
par les routes API et la commande artisan :

1. un tender **parcours** avec `ref_consultation` → `POST ingest` avec `fiche_ia` → SA fiche la
   porte, **aucune fiche créée**, la réponse rend son `tender_id`, et le ref **disparaît** de
   `GET a-approfondir` (présent avant, absent après) ;
2. `famille_ia` livrée → le ref disparaît de `GET a-classifier` ;
3. deux fiches partageant un ref → **les deux** reçoivent `fiche_ia` et `famille_ia`, et le
   statut/résultat posés par le gérant ne bougent pas ;
4. le statut d'une soumission n'est jamais basculé par une source automatique (et l'est toujours
   sur une fiche observée — comportement historique) ;
5. `a-approfondir` sert un ref une seule fois, et l'exclut dès qu'une fiche du ref est livrée ;
6. une fiche déjà classée en `familles_ia` (multiples) n'est pas re-servie ;
7. une fiche sans `ref_consultation` n'est pas proposée à la classification ;
8. la fusion : squelette + jumeau → champs IA, dossier, concurrence et étoile rapatriés, squelette
   soft-supprimé, bilan exact, **rejouable sans effet** ;
9. un squelette sans jumeau est laissé et compté.

Suite complète verte, Pint impeccable.

## Recette locale faite

- `php artisan veille:fusionner-squelettes` sur base de test reproduisant le cas (squelette
  PMMP-… + tender parcours jumeau) : « 1 squelette(s) fusionné(s) / 1 supprimé(s) / 0 sans
  jumeau (laissés). », relance → « 0 / 0 / 0 » ;
- `POST ingest` avec `fiche_ia` sur le ref d'un tender du parcours : réponse `tender_id` = la
  fiche EXISTANTE, statut « connu », aucun squelette.

## Après déploiement (prod)

1. `php artisan veille:fusionner-squelettes` — attendre au bilan au moins 3 fusionnés
   (651/855776 → tender 1, 652/912297, 653/941040) ;
2. vérifier `/veille/1` (fiche 51/2024/DAAJ) : la fiche extraite (travail à faire, bordereau,
   pénalités, estimation, famille) y est ;
3. au passage suivant de la moissonneuse : `855776`, `912297`, `941040` absents de
   `GET a-approfondir` ; le compteur `a-classifier` doit décroître franchement — s'il reste un
   socle incompressible, ce sont des slugs refusés : comparer les slugs du VPS aux familles
   actives de l'écran Familles.
