# Rapport — v3.36.0 (« Gains visibles » : bordereau propre à l'affichage, exigences nettoyées, libellé hébergement)

**Se livre aux DEUX installations, à l'identique. 1 migration additive & idempotente
(`2026_09_06_100000_renommer_famille_restauration_hebergement` — un `UPDATE` conditionné, rejouable,
base vide RUWAD comprise). Aucune dépendance composer, aucun asset compilé, AUCUNE écriture de
données métier.**

> **Règle d'or du lot : on ne SUPPRIME rien en base — on FILTRE à l'affichage.** La donnée brute
> reste intacte dans `notes` / `ai_analysis` et dans « Texte intégral du dossier ». Mieux vaut une
> section repliée ou masquée qu'un faux contenu affiché.

---

## Le pourquoi, en une phrase

La moissonneuse porte depuis sa v3.57 une garde `estVraiBordereau` **à l'extraction** : ce qu'elle
refuse aujourd'hui n'entre plus. Mais `--reclasser` relit le stock **sans ré-extraire les PDF** :
les ~105 fiches déjà en base qui affichent de la prose du CPS (« quantités réellement exécutées »,
nantissement, CCAGT, règlement de la consultation) comme « Bordereau des prix » ne se nettoient pas
toutes seules. Poser la **même garde côté app, au moment de l'affichage**, nettoie tout le stock
instantanément — sans reclasser une fiche ni retélécharger un seul DCE.

## Les trois volets

1. **§A** — la section « Bordereau des prix » se referme d'elle-même quand son seul contenu est de
   la prose du CPS ;
2. **§B** — les exigences et conditions perdent, à l'affichage, le bruit d'OCR et les lignes de
   sommaire du CPS, et gardent tout le reste ;
3. **§C** — la famille de slug `restauration-alimentation` s'affiche « **Restauration &
   hébergement** » : la moissonneuse y range désormais l'hôtellerie.

---

## §A — La garde d'affichage du bordereau

**`App\Support\FiltreTexteAo::estVraiBordereau(?string): bool`** — nouvelle classe de fonctions
pures, sans dépendance, testable seule. C'est le **port fidèle** de la fonction JS de la
moissonneuse : mêmes motifs, même ordre de décision, mêmes seuils (`nRows < 2`, `nLongues >= 3`),
validés sur 204 bordereaux réels (55/55 vraies tables gardées, 93/95 proses rejetées). Trois refus,
dans cet ordre : prose reconnaissable sans deux lignes numérotées ; ni en-tête ni lignes ; pavé de
longues phrases sans lignes ni titre.

Elle est appliquée au **point d'assemblage**, `FicheAoService::bordereau()`, et **sur la seule
branche texte brut** : `['bordereau']['texte']` devient `null` quand la zone n'est pas un
bordereau, `['bordereau']['lignes']` reste vide, et la condition `visible()` de
`FicheAo::bordereau()` tombe — la section se masque sans qu'on la touche. **Une table structurée
n'est jamais jugée** : dès qu'une ligne existe, le texte brut ne s'affiche déjà pas.

L'écran Filament (Veille et nos offres) et la **vue imprimable** lisent le même service : la garde
vaut pour les deux, sans une ligne de plus.

## §B — Le nettoyage des exigences & conditions

**`FiltreTexteAo::assainirLignesAo(array): array`** — assainisseur **conservateur** : il ne retire
qu'une ligne manifestement inutilisable, jamais une phrase française.

- amas de **≥ 5 consonnes** sans voyelle (`npplc`, `xbcqvjs`, `nlnscr`) — les URL en sont exemptées,
  elles en sont légitimement pleines ;
- **répétition ≥ 4** du même caractère (`nnnnn`, `ssssss`) — **lettres seulement** : appliquée aux
  chiffres, la règle aurait effacé « 200000 DH », qui est un montant et non du bruit. C'est le seul
  écart, délibéré, à la lettre du cahier : le principe directeur du volet est de ne jamais retirer
  une phrase normale ;
- **ligne de sommaire** du CPS : `Article|Chapitre N :` **et** numéro de page collé à la fin
  (« Article 19 : MODALITES DE REGLEMENT16 »).

Appliqué à l'assemblage pour l'affichage, dans `FicheAoService` : sur la liste `offre_technique`
avant sa jointure, et sur chaque valeur d'item dans `items()` — le point commun aux deux colonnes
(« Ce qu'il faut présenter » et « Conditions du marché »). Une valeur qui n'était QUE du déchet fait
disparaître son item ; **rien n'est écrit en base**.

## §C — « Restauration & hébergement »

`UPDATE familles_objet SET nom = 'Restauration & hébergement' WHERE slug =
'restauration-alimentation' AND nom = 'Restauration & alimentation'`, sous `hasTable`. **Le slug ne
bouge pas** : c'est lui que la moissonneuse émet et que les préréglages de filtres et les familles
prioritaires référencent. La condition sur l'ancien nom la rend rejouable et respecte un nom déjà
personnalisé par le gérant ; sur une base où la famille n'existe pas, zéro ligne touchée, zéro
erreur. Le cache du référentiel est vidé dans la foulée (`try/catch` : un cache indisponible pendant
la migration ne la fait pas échouer).

> **Datation.** La migration est datée `2026_09_06` et non du jour (`2026_09_01`) : elle DOIT tourner
> après `2026_09_05_100000_amorcer_familles_services_2026`, qui crée la famille. Datée du jour, elle
> serait passée AVANT la création sur une base neuve (RUWAD) et n'aurait rien renommé.

Aucun libellé « Restauration & alimentation » n'était écrit en dur dans le code (`LIBELLES_FAMILLES`
ne porte pas ce slug — le nom vient de la table) ; les deux assertions de
`tests/Feature/Veille/FamillesUnifieesV335Test.php` ont été alignées.

---

## Fichiers touchés

| Fichier | Volet |
| --- | --- |
| `app/Support/FiltreTexteAo.php` *(neuf)* | §A + §B |
| `app/Services/Tenders/FicheAoService.php` | §A (`bordereauBrut()`), §B (`items()`, `assainir()`, `presenter()`) |
| `database/migrations/2026_09_06_100000_renommer_famille_restauration_hebergement.php` *(neuf)* | §C |
| `tests/Unit/Support/FiltreTexteAoTest.php` *(neuf)* | §A + §B |
| `tests/Feature/Veille/GainsVisiblesV336Test.php` *(neuf)* | §A + §B + §C |
| `tests/Feature/Veille/FamillesUnifieesV335Test.php` | §C (libellé attendu) |

`app/Filament/.../Schemas/FicheAo.php` n'a pas bougé : sa condition `visible()` suffisait, et c'est
exactement ce qu'on voulait — la garde se pose sous les sections, pas dans chacune d'elles.

## Les cas de test

**Unitaires (`FiltreTexteAoTest`, 11 tests)** — rejetés : la prose CPS (« … aux quantités réellement
exécutées … le nantissement est régi par les dispositions de l'article 11 du CCAGT … la révision des
prix … »), un pavé de longues phrases, une zone vide ou nulle. Gardés : la table
« Bordereau des prix - Détail estimatif » à trois lignes numérotées, et une table sans titre reconnue
par ses colonnes. Lignes retirées : `Article 19 : MODALITES DE REGLEMENT16`, `xbcqvjs drcfm nlnscr`,
`nnnnnnn`. Lignes gardées : la pénalité de retard, « Caution provisoire : 10 000,00 DH. », la note
sur les moyens humains, un montant collé « 200000,00 DH », une URL, et un vrai
« Article 19 : le règlement intervient… » (sans numéro de page).

**Recette (`GainsVisiblesV336Test`, 5 tests)** — une fiche dont le seul bordereau est de la prose ne
rend **pas** la section (et sa `fiche_ia.bordereau_texte` est **inchangée** après affichage) ; un
vrai bordereau brut reste affiché ; une table structurée n'est jamais jugée ; le bruit et le
sommaire disparaissent des deux colonnes sans emporter les vraies conditions, et la photo du dossier
reste intacte ; la famille s'affiche « Restauration & hébergement » sous son slug inchangé.

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

1. Ouvrir une fiche de veille connue pour afficher la prose du CPS en « Bordereau des prix » : la
   section a disparu, et « Texte intégral du dossier » porte toujours le texte ;
2. ouvrir une fiche à vrai bordereau (structuré ou brut) : rien n'a changé ;
3. le filtre familles de la Veille propose « Restauration & hébergement » et retrouve les mêmes
   fiches qu'avant (le slug n'a pas bougé).
