# Rapport — v3.22.1 (hotfix : veille UTF-8 sûre, rattrapage tolérant, familles selon la nature)

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

## Le constat

Deux pannes et un faux classement, tous les trois vus en production après la v3.22.0.

**1. `veille:rattraper` s'arrêtait sur une puce.** Un objet du portail commençant par « • » faisait
tomber la commande entière :

```
SQLSTATE[HY000]: General error: 1366 Incorrect string value: '\xA2 R\xC3\xA9a...' for column 'object'
```

La cause n'était pas la puce, mais la façon de la retirer. `VeilleBlocSource::propre()` rognait ses
extrémités avec `trim($s, " \t\n\r\0\x0B-–—:;,.")` — or `trim()` avec une liste de caractères
travaille **octet par octet**. Le tiret demi-cadratin de cette liste (`E2 80 94`) partage ses deux
premiers octets avec la puce (`E2 80 A2`) : `trim` mangeait `E2` et `80`, et laissait `A2` tout
seul. Aucune base `utf8mb4` n'accepte cet octet orphelin. Et comme la commande ne rattrapait aucune
exception, la première fiche fautive privait de rattrapage **toutes** les suivantes.

**2. L'acheteur bavait.** Le format « en ligne » du portail colle parfois la référence, ou son
premier segment, en fin de nom d'organisme : « DIRECTION PROVINCIALE … D'ERRACHIDIA - ERRACHIDIA
**21** », alors que la référence de l'AO est « 21/2026/DPJE ».

**3. Filtrer « Impression & Édition » remontait des Fournitures.** `familleAffichee()` déduisait la
famille du **seul objet**, par mots-clés. Un marché de fournitures dont l'objet portait
« impression » tombait donc dans notre métier d'impression — alors qu'IRADA comme Ruwad ne
soumissionnent que sur des prestations de **services**.

## §1 — UTF-8 sûr dans `VeilleBlocSource`

- Nouvelle constante `ROGNE` : une **classe de caractères `/u`**, jamais une liste d'octets. Le
  rognage passe par `rogner()`, qui raisonne en caractères et ne peut donc plus casser le moindre
  glyphe.
- Nouvelle constante `PUCES` : « • », « ● », « ▪ », « ▫ », « ∙ », « · », « * », « + », « › »… Les
  puces de tête d'un objet sont retirées **proprement**, en multi-octets.
- Nouvelle méthode publique `assainir()` : le texte rendu en UTF-8 **valide**, octets orphelins
  retirés (`mb_check_encoding` puis `iconv(…//IGNORE)`). Elle est appelée **en tête de
  `normaliser()`**, c'est-à-dire à l'entrée de tout ce que la classe sait faire : rien de cassé ne
  peut plus en ressortir.
- Deux arithmétiques d'octets disparaissent : `reference()` et `montantApres()` découpaient par
  offsets `preg_match(PREG_OFFSET_CAPTURE)` + `substr`. Elles passent par `preg_split` et
  `mb_substr` — une fenêtre de lecture ne peut plus tomber au milieu d'un caractère accentué.

## §2 — `veille:rattraper` va jusqu'au bout

Chaque fiche est traitée dans son propre `try/catch`. Une fiche qui échoue malgré tout est
**ignorée, journalisée** (`Log::warning` avec son `tender_id` et le message d'erreur) et **comptée**.
Le bilan final devient :

```
12 fiche(s) rattrapée(s), 340 ignorée(s), 1 en erreur.
Fiche(s) en erreur : 918 — voir le journal.
```

La commande reste idempotente, et tient toujours sur une base vide.

## §3 — Même garde à l'ingestion

`VeilleImportService::normaliser()` assainit désormais chaque champ texte avant écriture
(`reference`, `buyer_name`, `object`, `winner_name`, `notes`, …). C'est le **point de passage
unique** de tout ce que le socle écrit sur une fiche : ingestion, import CSV, suivi de commission,
rattrapage. Un objet contenant « • », un texte copié d'un PDF ou un bloc tronqué par la moissonneuse
au milieu d'un caractère ne peuvent plus faire tomber une création de fiche ni rendre un 500.

## §4 — L'acheteur nettoyé de ce qui n'est pas son nom

`VeilleBlocSource::nettoyerAcheteur()` retire, **en toute fin** de nom et seulement après un espace :

- un jeton manifestement de **référence** — `21/2026/DPJE`, `12-2026`, `3/2026/CR` ;
- un **numéro isolé de 1 à 4 chiffres**, celui du premier segment de la référence en premier lieu
  (le même bloc l'a déjà donnée via `reference()`).

Deux prudences, pour ne jamais amputer un vrai nom : on ne retire que ce qui n'est pas un mot, et on
refuse de laisser un nom vide ou réduit à des chiffres. Multi-octets, idempotent.

Le **rattrapage re-découpe l'acheteur** depuis le bloc source conservé dans `notes`, et pas
seulement quand l'objet est encore brut — c'est ainsi que les fiches déjà en base se corrigent.
Sans transgresser la règle du socle : `estRemplacable()` n'autorise la réécriture que si le
**nettoyage de la valeur stockée rend EXACTEMENT la valeur proposée**. Un nom saisi à la main ne
répond jamais oui à cette question.

## §5 — La famille d'un AO respecte la nature du marché

**Sans migration.** Le bloc source du portail — « AOO Ouvert **Services** 20/08/2026 … Objet : …
Acheteur public : … » — est conservé tel quel dans `notes` à l'ingestion depuis la v3.19.0. La
nature s'y lit.

- **`VeilleBlocSource::nature()`** rend `'services'`, `'fournitures'`, `'travaux'` ou `null`. Elle
  lit **l'en-tête seul** : la portion « Objet : … » et le nom de l'acheteur sont écartés avant la
  recherche — sinon « **Fourniture** et impression de supports » ou « prestation de **services** »
  feraient un faux positif à tous les coups. Le mot doit être **entier**. Un texte sans les repères
  du portail (bloc tronqué, libellé nu) rend `null`.
- **`TenderProjectionService::natureMarche(Tender)`** lit la nature dans `notes`, à défaut dans
  `object` (fiche pas encore découpée), à défaut `null`.
- **`familleAffichee($objet, ?$nature)`** : Fournitures → `fournitures`, Travaux → `travaux` ;
  Services ou nature inconnue → la logique par l'objet, **inchangée**.
- **`famille($objet, ?$nature)`** : Fournitures/Travaux → `null`. Ce n'est pas un de nos métiers, et
  la projection de prix ne doit pas rapprocher un marché de fournitures homonyme.
- Le paramètre `$nature` est **facultatif** : tous les appels qui ne le passent pas gardent
  exactement le comportement d'avant.
- **Deux familles affichées de plus** dans `LIBELLES_FAMILLES`, juste avant « Autre » :
  `Fournitures` et `Travaux`. Comme « Autre », elles ne sortent jamais de `famille()`.
- **Câblage** : le filtre `FiltresAo::famille()` (qui charge désormais `id` + `object` + `notes`),
  l'infolist de la veille, et les appels internes de la projection de prix (AO de référence **et**
  candidats).
- **`ReglagesAo::optionsFamilles()`** — le picker des familles **prioritaires** — exclut
  « Fournitures » et « Travaux » comme il excluait déjà « Autre » : on ne priorise pas une nature
  qu'on ne soumissionne pas.

**Ni rattrapage ni migration** : la famille est calculée à l'affichage. Les fiches déjà en base se
reclassent d'elles-mêmes dès la mise en ligne.

## La recette

`tests/Feature/Veille/VeilleUtf8EtNatureTest.php` — 22 tests :

- un objet « • Réalisation d'études… » se découpe proprement, en UTF-8 valide (six formes de puces
  couvertes) ;
- un bloc coupé au milieu d'un caractère s'ingère sans casse et sans 500 ;
- `veille:rattraper` compte une fiche en erreur, la journalise, **traite les suivantes** et rend son
  bilan ; il reste idempotent ;
- « … VILLE DEMO 21 » (réf « 21/2026/DPJE ») → acheteur nettoyé, un vrai nom n'est jamais amputé, et
  un acheteur **saisi à la main** reste intact ;
- la nature se lit dans l'en-tête même quand objet et acheteur portent les trois mots ; un libellé
  nu ne donne aucune nature ;
- filtrer « Impression & Édition » ne renvoie **que** des Services ; Fournitures et Travaux
  s'isolent ; les deux natures sont au filtre mais jamais aux priorités.

**Non-régression** : la suite complète du socle passe, enrichissement v3.19 et finitions v3.22.0 §8
compris.

## À faire après déploiement

Un seul geste, facultatif mais utile — il corrige les fiches déjà en base (objets à puce restés
bruts, acheteurs qui traînent leur référence) :

```
php artisan veille:rattraper
```

Rien d'autre : pas de migration, pas de `composer install`, et le reclassement des familles se fait
tout seul à l'affichage.

## Le cas qui n'est PAS réglé ici

L'acheteur **tronqué à 300 caractères** — « DIRECTEUR » tout seul — vient de la moissonneuse, qui ne
garde que 300 caractères du bloc. Cela se règle côté VPS, pas dans le socle. Rien n'a été inventé
pour compléter un nom coupé : on ne devine pas un organisme.
