# Rapport — v3.37.0 (« Veille : bordereau propre, date de publication, filtre %, une seule Fournitures / une seule Travaux, rattrapage ciblé »)

**Se livre aux DEUX installations, à l'identique. 2 migrations additives & idempotentes
(`2026_09_07_100000_add_published_at_to_tenders_table` — `hasColumn` ;
`2026_09_08_100000_amorcer_familles_fournitures_et_travaux` — `hasTable` + `insertOrIgnore`),
rejouables, base vide RUWAD comprise. Aucune dépendance composer, aucun asset compilé, AUCUNE
donnée effacée ni migrée.**

> **Règle du gérant, portée dans les cinq volets : « il n'existe aucun AO sans bordereau de prix ».**
> Un bordereau absent est un défaut de chaîne, jamais un état normal. L'app n'efface rien : elle
> filtre à l'affichage, et sait dire à la moissonneuse quoi refaire.

---

## Le pourquoi, en une phrase

La moissonneuse **v3.58** est en prod : elle lit les bordereaux livrés en `.xlsx`, redresse les
pages scannées couchées avant l'OCR, rejette la prose du CPS à la source avec une garde **v2**
(`estVraiBordereau`), envoie une **`date_publication`** sur chaque fiche et sait lancer un
**rattrapage ciblé** (`--rattrapage --bordereaux`). Ce lot met l'app au niveau : la même garde à
l'affichage, la date stockée et triée, le filtre par part de famille, une seule famille Fournitures
et une seule Travaux, et la file de rattrapage.

## Les cinq volets

1. **§A** — la garde d'affichage du bordereau passe en **version 2**, port fidèle de la
   moissonneuse v3.58 ;
2. **§B** — la **date de mise en ligne** est stockée, triée et affichée ;
3. **§C** — le filtre famille gagne une **part minimale (%)** ;
4. **§D** — il n'y a plus qu'**une** « Fournitures & équipements » et qu'**une**
   « Travaux & installation » ;
5. **§E** — `GET /api/veille/a-approfondir?bordereau=manquant`, la **file de rattrapage ciblé**.

---

## §A — La garde d'affichage du bordereau, version 2

`App\Support\FiltreTexteAo::estVraiBordereau()` remplace sa logique v1 par le **port fidèle** de la
v2 de la moissonneuse — mêmes regex, mêmes seuils, même ordre. Rien d'autre n'a bougé dans la
classe : `assainirLignesAo()` et les points d'application (`FicheAoService::bordereauBrut()`, sur la
seule branche texte brut) sont intacts.

**Ce que la v1 laissait passer, et pourquoi :**

| Écart v1 → v2 | Effet |
| --- | --- |
| `\s+` → `[ \t]+` entre le numéro et la lettre | Une rangée de tableau tient **sur une seule ligne**. Le règlement du CPS, dont chaque alinéa commence par un petit numéro suivi d'un retour à la ligne, cesse d'être compté comme une table. C'est le cas de `/veille/205` et `/veille/507`. |
| Nouvelle règle **(2)** : `prose && nRows ≥ 2 && nLongues ≥ 3 × nRows && ! enteteCols` | Une fenêtre où deux ou trois rangées sont noyées sous trois fois plus de longues phrases, sans vrais en-têtes de colonnes, est de la prose mise en page. |
| En-têtes de titre : `+ décomposition du montant global`, `n° de/du prix|poste`, `désignation de la/des prestation(s)` | Trois formes de **vrais** bordereaux que la v1 masquait (DMG de l'ONEE, notamment). |
| En-têtes de colonnes : `prix (unitaire\|forfaitaire)` et la négation élargie | Le « prix forfaitaire » vaut le « prix unitaire » en tête de colonne. |

**Verdict des 10 fixtures — 10/10 conformes à `ATTENDU.json`** (déplacées dans
`tests/Fixtures/Bordereau/`, noms inchangés) :

| Fixture | Attendu | Obtenu |
| --- | --- | --- |
| `prose-205-nantissement` | `false` | `false` ✅ |
| `prose-507-reglement-onee` | `false` | `false` ✅ |
| `prose-onee-decomposition-boilerplate` | `false` | `false` ✅ |
| `prose-113-revision-des-prix` | `false` | `false` ✅ |
| `modele-vide-154` | `false` | `false` ✅ |
| `tableau-paiement-9` | `false` | `false` ✅ |
| `reel-125-detail-estimatif-phases` | `true` | `true` ✅ |
| `reel-129-dmg-un-poste` | `true` | `true` ✅ |
| `reel-137-dmg-phases` | `true` | `true` ✅ |
| `reel-186-dmg-entete` | `true` | `true` ✅ |

Le test paramétré (`#[DataProvider]`) lit `ATTENDU.json` : ajouter une fixture au dossier suffit à
l'ajouter au jury. Les **11 tests unitaires existants** et `GainsVisiblesV336Test` restent verts
sans une ligne modifiée.

## §B — La date de mise en ligne, stockée et triée

1. **Migration** — `tenders.published_at` (`date`, nullable, indexée), sous `hasColumn`.
2. **Ingestion** — `VeilleImportService::appliquerDatePublication()` accepte `date_publication` en
   ISO `yyyy-mm-dd` et tolère `dd/mm/yyyy`. `Carbon::parse()` n'est **pas** utilisé (il accepte
   « demain » et lirait `01/09/2026` comme le 9 janvier) : deux formats stricts, avec vérification
   que la date se **réécrit à l'identique** — `31/02/2026` est refusé au lieu d'être « rattrapé »
   au 3 mars. On complète, et une valeur déjà posée n'est remplacée **que par une date
   antérieure** : une réédition ne rajeunit pas une consultation d'il y a trois semaines. La date
   est **propagée aux jumelles** du même `ref_consultation` — c'est la même consultation.
3. **Tri par défaut de la Veille** — `ORDER BY COALESCE(published_at, DATE(created_at)) DESC, id
   DESC`. Les fiches d'avant la v3.58, sans `published_at`, se rangent sur leur date de **première
   détection** : c'est voulu, et c'est la meilleure approximation qu'on ait.
4. **Colonne « Mis en ligne »**, avant « Date limite » : la date en `d/m/Y`, et en description
   « il y a N j » / « aujourd'hui ». Triable (le tri de colonne reprend le même `COALESCE`, pour
   que la colonne et l'ordre disent la même chose). Sans `visibleFrom`, elle s'empile avec
   l'essentiel sur mobile, comme la date limite.
5. **Fiche** — « Mis en ligne » entre dans la grille des champs, juste avant « Ouverture des plis ».
   C'est le **seul champ conditionnel** de la grille : sans date, la case n'apparaît pas du tout —
   un « — » de plus n'apprend rien, et l'absence est la règle sur tout le stock ancien.

### Ce qui a été RETIRÉ du tri par défaut, et pourquoi

`VeilleTable::trierParPertinence()` et `VeilleTable::pertinence()` **ont disparu**, ainsi que leur
unique matière `TenderProjectionService::partFamillesActives()`.

Le tri par pertinence (v3.32.0 §C3) valait `part cumulée dans les familles actives × proximité
d'échéance`. La famille d'une fiche n'est **pas une colonne** : c'est une lecture d'`ai_analysis` et
de mots-clés. Le tri chargeait donc **toutes les fiches ouvertes en mémoire à chaque affichage de la
page**, calculait un score pour chacune, puis construisait un `CASE id WHEN … THEN …` de plusieurs
centaines de branches injecté dans la requête. C'est la cause principale de la lenteur de la liste
constatée par le gérant. Le nouveau tri se joue **entièrement en SQL**, sur une colonne indexée.

La question « quelle est la part de cette fiche ? » n'a pas disparu — elle se pose désormais **par
rapport à des familles précises**, celles que le filtre a cochées, et c'est
`TenderProjectionService::partDesFamilles()` qui y répond (§C). Le **tri manuel par colonne** reste
intact partout.

## §C — Le filtre par part de famille (« Part minimale »)

Le filtre `FiltresAo::famille()` attrapait un AO dès qu'une famille cochée y apparaissait, **à
n'importe quelle part** : un marché à 5 % de digital sortait sous « Digital & Marketing ».

Un champ **« Part minimale (%) »** (entier 0-100, **défaut 50**) pose le seuil. Il ne s'applique que
si au moins une famille est cochée ; à 0, le comportement d'avant le lot, à l'identique. Le libellé
sous le champ dit la règle : *« Plusieurs familles cochées : leurs parts s'additionnent (30 % Digital
+ 30 % Dév. info = 60 %). »* L'indicateur du filtre affiche « ≥ 50 % ».

`TenderProjectionService::partDesFamilles(Tender, array $familles): int` — trois règles, dans cet
ordre :

1. une famille-**nature** cochée que la ventilation ne cite pas vaut **100** : le marché EST de
   cette nature, une ventilation de bordereau ne le contredit pas ;
2. avec ventilation : la **somme** des parts des familles cochées, une part inconnue valant
   `round(100 / nombre de familles)` ;
3. sans ventilation : **100** si l'une des familles de la fiche (nature, famille IA, mots-clés,
   métier reconnu sous une nature, « Autre ») est cochée — la **même liste** que celle du filtre,
   pour que ce qui sort du filtre ne puisse pas se voir opposer une part de zéro.

> **Note technique.** Le filtre passe de `SelectFilter` à `Filter` (il porte deux champs, plus un
> seul), mais **la clé d'état de la sélection ne bouge pas** : `famille.values`, la forme d'un
> `SelectFilter` multiple. C'est ce qui permet aux **préréglages v3.28 déjà enregistrés** de
> continuer à s'appliquer sans rien migrer. Un préréglage d'avant le lot n'a pas de `part_min` :
> le seuil du socle (50) s'applique alors, comme partout ailleurs.

## §D — Une seule « Fournitures & équipements », une seule « Travaux & installation »

**Le bug de filtre du gérant.** L'app portait **deux** Fournitures et **deux** Travaux : la *nature*
forcée par le portail (`FAMILLE_FOURNITURES = 'fournitures'`, `FAMILLE_TRAVAUX = 'travaux'`,
affichées « Fournitures »/« Travaux ») et les *familles de la table* posées par le VPS
(`fournitures-equipements`, `travaux-installation`). Les deux figuraient dans le menu du filtre et
**ne rendaient pas les mêmes fiches**. Décision du gérant : *« travaux et fournitures sont deux
familles distinctes »* — une de chaque.

1. **La nature résout vers la famille de table.** Les constantes deviennent
   `FAMILLE_FOURNITURES = 'fournitures-equipements'` et `FAMILLE_TRAVAUX = 'travaux-installation'` ;
   elles quittent `LIBELLES_FAMILLES` (leurs noms viennent de la table, comme ceux de toutes les
   autres) et le menu du filtre. `FAMILLE_AUTRE` reste.
   La résolution vit à un seul endroit, `TenderProjectionService::familleDeNature()`.

   > ⚠️ **`natureMarche()` continue de rendre la nature telle qu'écrite au portail** (`services` /
   > `fournitures` / `travaux`). C'est elle que `famille()` compare aux constantes de
   > `VeilleBlocSource` pour écarter les marchés hors services : la déplacer aurait cassé le
   > classement par mots-clés **en silence**. C'est la RÉSOLUTION qui change de cible, pas la
   > lecture du portail.

2. **Compatibilité — remappage à la lecture.** `FamillesObjetService::normaliserSlug()` (et
   `normaliserSlugs()`) centralisent la correspondance `fournitures → fournitures-equipements`,
   `travaux → travaux-installation`. **Aucune migration de données.**
3. **Le métier n'est plus masqué.** `TenderProjectionService::metierDeFiche()` rend le métier
   reconnu par l'IA locale quand la fiche est montrée sous sa nature et que le métier diffère. Il
   s'affiche en **second badge discret** — « métier : Développement & informatique » — sur la ligne
   (`ColonneFamille`, deux badges empilés, le second en gris) comme sur la fiche (`fao-fam--metier`,
   à l'écran et à l'impression).
4. **Les deux filtres rendent la fiche.** `famillesDeFicheAffichees()` ajoute le métier en queue :
   filtrer « Fournitures & équipements » rend les marchés de nature fournitures **et** ceux ventilés
   fournitures ; filtrer « Développement & informatique » rend aussi la fiche de fournitures que
   l'IA a classée là. Sans cela, unifier les deux Fournitures aurait rendu ces fiches introuvables
   par leur métier — on aurait échangé un bug contre un autre.

### Où un ancien slug `fournitures` / `travaux` est remappé

| Endroit | Ce qui est normalisé |
| --- | --- |
| `FamillesObjetService::normaliserSlug()` / `normaliserSlugs()` | **La source unique** de la correspondance (`SLUGS_HISTORIQUES`). |
| `TenderProjectionService::familleActive()` | `ai_analysis.famille_ia` et chaque entrée de `ai_analysis.familles_ia` — donc `familleIa()`, `famillesIa()`, `familleDeFiche()`, `familleAfficheeDeFiche()`, `famillesDeFicheAffichees()`. |
| `TenderProjectionService::libelleFamille()` | Le libellé d'un slug ancien (« Fournitures & équipements », plus « Autre »). |
| `TenderProjectionService::partDesFamilles()` | Les slugs cochés passés par l'appelant. |
| `FamillesObjetService::couleur()` | La couleur d'un slug ancien vient du référentiel, plus d'une valeur en dur. |
| `FiltresAo::famillesCochees()` | Les valeurs du filtre — donc **les préréglages v3.28** rappelés. |
| `ReglagesAo::famillesPrioritaires()` | Le réglage `ao.familles_prioritaires`. |
| `ReglagesAo::famillePrioritaire()` | Les deux côtés de la comparaison, à l'émission d'une alerte « nouvel AO ». |

**Amorçage.** Les deux familles existent déjà en base sur IRADA comme sur RUWAD
(`2026_09_03_100000_create_familles_objet_table` pour `fournitures-equipements`,
`2026_09_04_100000_amorcer_famille_travaux_installation` pour `travaux-installation`). La migration
de ce lot est le **filet**, pour une installation dont le référentiel aurait été retouché à la main :
`insertOrIgnore` sur le slug unique, une famille déjà présente n'est jamais réécrite.

> **Écart assumé au cahier.** Le cahier demandait d'amorcer les deux « mots-clés vides comme
> `travaux-installation` ». `fournitures-equipements` est amorcée **avec ses mots-clés d'origine**
> (`acquisition`, `fourniture`, `dotation en materiel`, `=equipement(s)`) : sur la seule base où
> cette migration écrirait vraiment quelque chose, une famille sans mots-clés ne serait plus jamais
> posée par le classement par mots-clés — une régression silencieuse. `travaux-installation` reste
> sans mots-clés, comme elle l'a toujours été et pour la raison d'origine (« travaux » est un
> marqueur hors cible).

## §E — La file de rattrapage ciblé

`GET /api/veille/a-approfondir?bordereau=manquant` — **pas de nouvelle route, pas de migration**.
Une fiche est « sans bordereau crédible » quand `fiche_ia.bordereau` (lignes structurées) est vide
**et** que `fiche_ia.bordereau_texte` est vide ou échoue `FiltreTexteAo::estVraiBordereau()` : la
**même garde qu'à l'affichage**, donc la même définition de « manquant » des deux côtés de l'écran.
Si les deux divergeaient, la file re-servirait éternellement des dossiers que l'écran juge bons, ou
tairait ceux qu'il masque.

Trois points de mécanique :

- le critère historique « **sans photo** » ne s'applique **pas** dans cette file, et c'est tout son
  intérêt : les fiches à refaire sont justement celles qui ont déjà une photo, mais dont le
  bordereau n'en est pas un ;
- `version=` **continue de s'appliquer** par-dessus : une fiche refaite en 3.58 sort de la file,
  quel que soit son bordereau. Un `bordereau=` hors contrat est refusé en **422** plutôt qu'ignoré —
  servir la file entière à une moissonneuse qui croit demander les bordereaux manquants lui ferait
  retélécharger tout le stock pour rien ;
- les **clôturées restent éligibles** (le gérant veut le stock entier), simplement servies après les
  AO encore ouverts, dans l'ordre du rattrapage v3.34.

La file se **parcourt** (`cursor()`) au lieu de se limiter : le verdict se rend fiche à fiche, et une
passe avancée n'a plus qu'une poignée de manquants au fond d'un stock de plusieurs milliers — un
`LIMIT` généreux aurait rendu une file vide alors qu'il reste du travail. On s'arrête dès qu'on tient
la limite demandée, une fiche par `ref_consultation`.

---

## Fichiers touchés

| Fichier | Volet |
| --- | --- |
| `app/Support/FiltreTexteAo.php` | §A (`estVraiBordereau` v2 ; le reste intact) |
| `tests/Fixtures/Bordereau/` *(neuf — 10 `.txt` + `ATTENDU.json`)* | §A |
| `tests/Unit/Support/FiltreTexteAoTest.php` | §A (test paramétré sur les 10 fixtures) |
| `database/migrations/2026_09_07_100000_add_published_at_to_tenders_table.php` *(neuf)* | §B1 |
| `app/Models/Tender.php` | §B1 (`published_at` : `fillable` + cast `date`) |
| `app/Services/Tenders/VeilleImportService.php` | §B2 (`appliquerDatePublication()`, propagation aux jumelles, contrat documenté) |
| `app/Http/Controllers/Api/VeilleIngestController.php` | §B2 (`date_publication` au contrat) |
| `app/Filament/Admin/Resources/Veille/Tables/VeilleTable.php` | §B3 (tri par défaut ; retrait de `trierParPertinence()`/`pertinence()`), §B4 (colonne « Mis en ligne ») |
| `app/Services/Tenders/FicheAoService.php` | §B5 (« Mis en ligne » dans la grille), §D3 (badge métier) |
| `app/Filament/Admin/Resources/Tenders/Tables/FiltresAo.php` | §C (« Part minimale »), §D2 (`famillesCochees()`) |
| `app/Services/Tenders/TenderProjectionService.php` | §C (`partDesFamilles()`), §D1 (constantes, `familleDeNature()`, `familleNatureDeFiche()`, `metierDeFiche()`), §D2 (`familleActive()`, `libelleFamille()`), §B3 (retrait de `partFamillesActives()`) |
| `app/Services/Tenders/FamillesObjetService.php` | §D1 (`libelles()`), §D2 (`SLUGS_HISTORIQUES`, `normaliserSlug()`, `normaliserSlugs()`, `couleur()`) |
| `app/Support/ReglagesAo.php` | §D2 (familles prioritaires normalisées) |
| `app/Filament/Support/ColonneFamille.php` | §D3 (second badge « métier : … ») |
| `app/Support/FicheAoHtml.php` | §D3 (classe `fao-fam--metier`) |
| `public/css/socle-dense.css`, `resources/views/fiche-ao/imprimable.blade.php` | §D3 (le badge discret, écran + impression) |
| `database/migrations/2026_09_08_100000_amorcer_familles_fournitures_et_travaux.php` *(neuf)* | §D2 |
| `app/Http/Controllers/Api/VeilleAApprofondirController.php` | §E |
| `tests/Feature/Veille/VeilleV337Test.php` *(neuf)* | §B + §C + §D + §E |

### Tests existants ADAPTÉS (aucun supprimé sans remplacement)

| Fichier | Ce qui change |
| --- | --- |
| `FicheAoV332Test` | `test_le_tri_par_defaut_de_la_veille_met_la_pertinence_en_tete` → `…_met_la_mise_en_ligne_la_plus_recente_en_tete` : **mêmes trois fiches**, nouvelle règle (§B3). |
| `VeilleEnrichissementTest` | `test_le_tri_par_date_limite_…` affirme désormais l'ordre par défaut §B3 **puis** le tri par échéance dans les deux sens, qui reste à un clic. |
| `VeilleUtf8EtNatureTest` | `familleAffichee($objet, 'fournitures')` rend `fournitures-equipements` ; le menu propose les deux natures **sous leur famille de table**, plus de slug nu. |
| `FamillesObjetTest` | `optionsFamilles()` n'a plus la clé `fournitures` mais `fournitures-equipements`. |
| `FrictionsUiTest` | L'état de la colonne `famille` est désormais une **liste** de libellés (un seul élément hors marché montré sous sa nature). |
| `ParcoursAoTest`, `PresetsEtDossierEtoileTest`, `VeilleEnrichissementTest`, `FamillesObjetTest`, `FicheAoV332Test`, `VeilleUtf8EtNatureTest` | `filterTable('famille', [...])` → `filterTable('famille', ['values' => [...]])` : le filtre porte deux champs, l'aide de test ne peut plus deviner lequel. La clé d'état, elle, est inchangée. |

Les tests qui citent les constantes `FAMILLE_FOURNITURES` / `FAMILLE_TRAVAUX` par leur nom
(`FamilleIaTest`) suivent leur nouvelle valeur sans une ligne modifiée.

## Après déploiement — ce qu'il faut vérifier

1. Ouvrir `/veille/205` et `/veille/507` : la section « Bordereau des prix » a disparu ; ouvrir une
   fiche à DMG (`/veille/129`, `/veille/186`) : elle est là ;
2. la liste « Veille du marché » s'ouvre **vite**, et la colonne « Mis en ligne » est en tête
   d'ordre, la plus récente en haut ;
3. le filtre « Famille d'objet » propose **une** « Fournitures & équipements » et **une**
   « Travaux & installation », et plus aucun « Fournitures » ni « Travaux » nu ;
4. cocher « Digital & Marketing » avec « Part minimale » à 50 : les AO où le digital est marginal
   ne sortent plus. Un préréglage enregistré avant le lot se rappelle et rend les mêmes fiches ;
5. lancer la moissonneuse en `--rattrapage --bordereaux` : la file rend des fiches, et elle se vide
   à mesure des passes.
