# Rapport — v4.0.0 (« Repartir de zéro » : types de consultation, lots, bons de commande, compte rendu de lecture, état de la veille, purge)

**Se livre aux DEUX installations, à l'identique. 5 migrations additives & idempotentes
(`hasTable` / `hasColumn`), rejouées deux fois sans erreur, base vide RUWAD comprise. AUCUNE
dépendance composer, aucun asset compilé, AUCUNE donnée effacée ni migrée par la livraison.**

> **La purge n'a PAS été exécutée.** `veille:purger --confirmer` n'a été lancé nulle part — ni en
> local, ni ailleurs. C'est le gérant qui la lance, après sauvegarde, selon le guide de
> déploiement. La commande livrée est en essai à blanc par défaut.

---

## Le pourquoi, en une phrase

Le gérant a décidé de **repartir de zéro** : tout ce qui vient de la veille sera supprimé puis
reconstruit par une moissonneuse v4 qui lit le portail à partir d'un corpus conservé et **rend
compte de tout**. Ce lot prépare l'app à recevoir cette chaîne — la typologie du portail en
colonnes, les lots, les bons de commande, le compte rendu de lecture, le battement de cœur du VPS,
et la purge sous garde.

## Le constat de départ (relevé sur le portail le 02/09/2026)

Le socle ne connaissait qu'**un seul type d'AO**. Le mode de passation n'était qu'un texte perdu
dans les notes. Conséquences, mesurées sur le stock réel :

| Ce que l'app faisait | Combien de fiches |
| --- | --- |
| Un AMI ou une phase 1 jugé « bordereau manquant » — faux par construction | 10 |
| Un concours ou une consultation architecturale mêlé au reste — hors métier | 4 |
| Un marché « au rabais » traité comme une offre de prix | 3 |
| Un AO alloti réduit à UN bordereau et UNE estimation | 47 (dont un à 70 lots) |
| Un bon de commande | **0** — le module n'a jamais été lu |

Le portail a **deux modules** : « Consultations » (29 types de procédure) et « Bon de commande —
Avis d'achat » (758 avis en cours, 316 284 résultats publiés), qui expose ses **articles
structurés en HTML** — aucun PDF à lire.

## Les six volets

1. **§A** — le modèle : typologie en colonnes, `tender_lots`, l'origine des lignes, `veille_passages` ;
2. **§B** — le **contrat d'ingestion v2**, sur la même route et le même jeton ;
3. **§C** — la **purge sécurisée**, essai à blanc par défaut ;
4. **§D** — les écrans, verrouillés par `MAQUETTE-VEILLE-V4.html` ;
5. **§E** — ce qui ne change pas, et la garde du compte rendu ;
6. **§F** — tests, qualité, livraison.

---

## §A — Le modèle

**§A1 — `tenders`, dix-neuf colonnes neuves.** Toutes nullables (sauf `lots_count` défaut 0 et
`annule` défaut false), indexées là où le cahier l'indique : `source`, `procedure_code`,
`procedure_libelle`, `forme_prix`, `categorie_portail`, `nature_bdc_code`, `nature_bdc_libelle`,
`bdc_id`, `date_limite_devis`, `lots_count`, `annule`, `motif_annulation`, `date_annulation`,
`compte_rendu`, `pieces`, `corpus_zip`, `corpus_zip_octets`, `texte_portail`, `resultat_bdc`.

**`source` est la SEULE colonne rétro-remplie**, et sans rien inventer : `consultation` là où un
`ref_consultation` existe, `manuel` ailleurs. Aucune autre valeur n'est déduite d'un stock qui ne
la porte pas — le stock d'avant la v4 n'a donc ni type de procédure ni forme de prix, et l'écran
ne prétend pas le contraire (le badge ne s'affiche pas).

Deux énumérations : `App\Enums\SourceAo` (Marché / Bon de commande / Saisie manuelle) et
`App\Enums\FormePrix` (sur offre de prix / au rabais-majoration / prix de départ / sans prix /
devis), chacune avec `label()` et `color()`. `FormePrix::attendUnBordereau()` porte, seule, la
portée exacte de la règle du gérant.

> **`texte_portail`, `compte_rendu` et `pieces` rejoignent `$auditExclude`.** Ce sont des documents
> entiers, de la même famille que `notes` — et c'est exactement ce qui avait fait déborder la
> colonne d'audit en production le 30/08/2026 (`1406 Data too long`, 47 livraisons perdues sur 55).

**§A2 — `tender_lots`.** Un lot = une décision. Le **lot 0** est le lot unique d'un AO NON alloti :
il donne un parent aux lignes de bordereau et **ne s'affiche jamais**. `lots_count` ne compte que
les lots de numéro ≥ 1 — c'est lui, et lui seul, qui dit qu'un AO est alloti. Unicité sur
(`tender_id`, `numero`) : une seconde livraison ne double jamais un lot.

**§A3 — `tender_bordereau_lines`** gagne `lot_id`, `origine`, `specs`, `tva`, `garanties`.
`origine` (`manuel` | `lecteur` | `portail`) est **la garde de la règle d'or** : l'ingestion ne
remplace que les lignes machine. Une migration de données crée le lot 0 de chaque AO qui porte des
lignes orphelines et les y rattache — idempotente, et sans toucher `lots_count`.

**§A4 — `veille_passages`.** Le battement de cœur : par module et par passage, ce que le portail
annonçait, ce qui est entré, les DCE conservés et les échecs, les bordereaux trouvés, les raisons
classées, les erreurs **nommées** (plafonnées à 200), et les **écarts** — une ligne du portail
restée sans fiche.

## §B — Le contrat d'ingestion v2

Écrit dans **`docs/contrats/INGEST-V2.md`**, avec un exemple complet par cas. **Les trois exemples
sont les fixtures des tests** (`tests/Fixtures/IngestV2/`) : ils ne peuvent pas se périmer sans que
la suite le dise.

**La v1 reste acceptée telle quelle.** Un envoi qui ne porte pas `"contrat": 2` suit exactement le
chemin de la v3.18 → v3.37, jusqu'aux clés de sa réponse. Aucun test existant n'a été modifié.

### Ce que `remplacer` remplace, et ce qu'il ne touche jamais

C'est le cœur du contrat, et il ne souffre aucune exception.

| Remplacé (MACHINE) | Jamais touché (HUMAIN) |
| --- | --- |
| Colonnes issues de `portail`, `texte_portail` | `favori`, `participation`, `status` |
| `fiche_ia`, `familles_ia`, `mode_passation_ia` | Notes humaines (le socle n'écrit que sous ses `=====`) |
| Lots, et lignes d'origine `lecteur` / `portail` | `drive_link`, `our_offer` |
| `pieces`, `compte_rendu`, `corpus_zip`, `resultat_bdc` | Lignes de bordereau d'origine **`manuel`** |
| | **Décision** et **notes** d'un lot |
| | Checklist, documents de préparation |
| | Tout champ marqué « saisi à la main » (règle v3.27.0 §C) |

Sans `remplacer` : la règle du socle, mot pour mot — on complète, on n'écrase pas.

### La table de vérité de la typologie

`App\Services\Tenders\TypologieAoService` porte les 29 types et la déduction de la forme de prix.
**Elle vit là et nulle part ailleurs** : si l'affichage et la file de rattrapage divergeaient d'un
cheveu, la moissonneuse retéléchargerait éternellement des AMI qui n'ont pas de bordereau à livrer,
ou l'écran masquerait des bordereaux qu'elle vient de trouver.

L'ordre de la déduction : **ce que la moissonneuse envoie prime toujours** ; sinon un avis d'achat
est un `devis` ; sinon le **code tranche** ; sinon un libellé qui porte « rabais » ou « majoration »
le dit ; sinon `offre_prix`.

> **Précision de mise en œuvre.** Le cahier écrit « sinon `offre_prix` (et `rabais` si le libellé
> porte "rabais") ». La lecture du libellé est donc placée **après** le code, et ne s'applique que
> là où celui-ci aurait dit « sur offre de prix » : un AMI dont le libellé porterait le mot
> « rabais » reste `sans_prix`. Le contraire aurait fait réclamer un bordereau à une candidature.

Un **code inconnu** de l'app reste lisible : son libellé du portail s'affiche tel quel. Le jour où
le portail ajoute un type, personne n'a à redéployer pour le voir.

### Les deux routes neuves

- **`POST /api/veille/passage`** — la moissonneuse ouvre un passage `en_cours`, puis renvoie le
  **même `id`** pour le refermer avec ses compteurs. Une mise à jour ne remet à zéro aucun
  compteur qu'elle ne porte pas.
- **`GET /api/veille/etat`** — le dernier passage de chaque module et le voyant, calculés par le
  **même service** que l'écran (`EtatVeilleService`). Les trois surfaces ne peuvent pas se
  contredire.

### §B3 — La file de rattrapage retrouve sa portée exacte

`GET /api/veille/a-approfondir?bordereau=manquant` **n'appelle plus** :

- les fiches dont `forme_prix ∈ {sans_prix, enchere, devis}` — elles n'ont pas de bordereau à livrer ;
- les fiches **annulées** — il n'y a plus rien à préparer ;
- les fiches dont `compte_rendu.bordereau = 'sans_objet'` — le VPS a ouvert le dossier et conclu ;
- **les fiches dont un LOT porte des lignes lues** : un AO alloti n'a pas un bordereau dans
  `fiche_ia`, il en a un par lot. (Ajout au cahier, sans quoi tout AO alloti correctement lu
  serait re-servi indéfiniment.)

> ⚠️ **Une `forme_prix` NULLE reste éligible**, et il fallait l'écrire : en SQL, un `NOT IN` sur une
> colonne nulle rend NULL — donc faux. Tout le stock d'avant la v4 aurait disparu de la file **en
> silence**.

## §C — La purge sécurisée

`php artisan veille:purger` — **l'essai à blanc est le défaut**, et il n'écrit rien : ni une fiche,
ni une ligne de journal. Il affiche quatre groupes avec leurs compteurs (fiches, lots, lignes,
résultats, checklist, documents) et les **vingt premières références** de chacun.

**Quatre gardes cumulatives, qu'aucune option ne lève :**

1. une offre `soumission` dont le statut n'est pas « en préparation » — elle porte notre histoire ;
2. une fiche référencée par `projects.tender_id` ou `guarantees.tender_id` — un marché, de l'argent ;
3. une fiche dont la référence figure dans `invoices.tender_reference` — elle est facturée ;
4. une fiche portant `drive_link` **ET** `participation = soumission` — un dossier préparé ailleurs.

Une fiche gardée n'est pas silencieusement écartée : elle est **listée**, avec sa raison.

`--confirmer` supprime définitivement (`forceDelete`) les deux groupes cibles et leurs dépendances,
**par paquets de 100, chacun dans sa transaction** — une interruption laisse une base cohérente.
Le journal des références part dans `storage/logs/purge-veille-<date>.log`. Le tableau « après »
est produit par le **même calcul** que le tableau « avant ». `--source=` limite la portée.

> **Lecture assumée du cahier.** Le cahier dit à la fois « refuse de tourner si une fiche du groupe
> "liée" est dans la cible » et « elle est simplement conservée et listée ». Les deux ne peuvent
> pas être vrais ensemble ; c'est la seconde qui a été retenue, parce qu'elle est la seule qui
> laisse le gérant purger sa veille : une fiche liée n'entre **jamais** dans la cible, elle en est
> exclue et affichée dans son groupe. Une purge qui refuserait de tourner tant qu'un projet existe
> ne tournerait jamais.

## §D — Les écrans

### §D1 — La liste

Colonnes, dans l'ordre de la maquette : Réf. · **Source · Type · Prix** · Acheteur · Famille ·
Objet · Mis en ligne · **Date limite** · Estimation · **Lecture**.

- les badges **Marché / Bon de commande**, le type abrégé (AOO, AMI, Présélection ph.1… ou, pour un
  avis d'achat, sa nature de prestation), la forme du prix, et **« N lots »** en violet ;
- la date limite d'un bon de commande porte l'heure et le suffixe **« devis »** ;
- l'estimation d'un AO alloti devient une **fourchette** — « 3 lots · 120 000,00 DH → 480 000,00 DH ».
  On ne répond pas à la somme d'un AO, on répond à un lot ;
- **« Lecture »** : « bordereau 15 postes », « 4 articles (portail) », « sans prix »,
  « dossier de candidature », « prix de départ », « catalogue N lignes » — ou, **en orange**, la
  raison classée. Jamais une case vide : un vide ne se distingue pas d'un défaut de chaîne.

**Cinq filtres neufs** : Source, Type de procédure (multi, cherchable), Forme de prix, Lots
(indifférent / allotis / non allotis), et les trois masques dans un seul filtre — **AMI visibles**,
**concours masqués**, **annulées masquées**, exactement comme le cahier les règle.

> Les défauts des masques suivent la règle de la « part minimale » v3.37 §C : un préréglage v3.28
> enregistré avant ce lot n'a aucune de ces clés, et le défaut du socle s'applique alors. Sans
> cela, rappeler un vieux préréglage aurait soudain fait ressortir les concours et les annulées.

**Famille et Montant regardent LES LOTS** : un AO passe si l'un de ses lots passe. Un AO à trois
lots dont un seul est du digital sortait sinon toujours, ou jamais — les deux sont faux.
`LotsAoService::partDuLot()` applique au lot les règles de `partDesFamilles()`.

### §D2 — La fiche (consultation)

**Rien n'a été retiré.** Tout ce que la v3.37 affichait est là. S'ajoutent :

- **en-tête** : les badges Source · Type · Prix (+ « N lots ») et, si annulée, le bandeau
  « Annulée le … — motif » ;
- **« Lots — on décide lot par lot »**, juste après l'en-tête, sur un AO alloti : Lot · Intitulé ·
  Famille · Estimation · Caution · Bordereau · **Décision** · Résultat, puis le **bordereau de
  chaque lot** en sections dépliables, la première ouverte. Sans allotissement, le lot 0 est
  invisible et la fiche garde exactement sa forme d'avant ;
- **« Compte rendu de lecture »**, avant « Texte intégral » : Pièce · Type réel · Lu par ·
  Résultat, la ligne de synthèse (« N pièces présentes, N lues · zip conservé (4,2 Mo) ·
  moissonneuse X · lu le … ») et la **raison classée en clair** quand le bordereau manque ;
- **par type** : `rabais` → le bordereau montre les prix de l'ACHETEUR, avec la mention qui le dit,
  et une action « Taux de rabais (%) » ; `sans_prix` → « Sans objet pour ce type : dossier de
  candidature » plus le bloc **« Dossier de candidature »** ; `enchere` → le bloc **« Enchère »**
  (prix de départ, calendrier, règles — « à confirmer sur le portail » à défaut) ; concours →
  fiche réduite avec la mention hors métier.

**Un bloc qui ne s'applique pas dit « sans objet pour ce type », jamais un vide silencieux.**

> **Le sélecteur de décision est un composant Livewire** (`App\Livewire\LotsAo`) et non du HTML
> statique, pour une raison unique : il doit **enregistrer à la volée**. On trie soixante-dix lots
> à la souris, pas en ouvrant soixante-dix modales. Il vérifie la permission d'écriture **et** que
> le lot appartient bien à la fiche — un identifiant venu du navigateur ne commande jamais une
> écriture à l'aveugle.

> **Le taux de rabais vit dans `ai_analysis.rabais_pct`, jamais dans `our_offer`.** `our_offer` est
> un montant en dirhams partout ailleurs dans le socle ; y ranger un pourcentage fausserait tout ce
> qui le lit — écarts, marges, indicateurs.

### §D3 — La fiche d'un bon de commande

Même gabarit. En-tête : badge « Bon de commande », nature de prestation, catégorie, mise en ligne,
**remise des devis le … à …**, annulation le cas échéant. Le bloc **« Articles (portail) »**
remplace « Bordereau des prix » : # · Article · **Spécifications repliées, dépliables** · Unité ·
Qté · TVA · Garanties. « Caution provisoire », « Caution définitive » et « Méthode du règlement »
affichent **« Sans objet (bon de commande) »**. « Résultat de l'avis d'achat » : devis reçus,
attributaire, montant TTC, et **l'écart avec notre devis** quand `our_offer` est saisi.

### §D4 — « État de la veille »

Page Filament dans « Marchés & appels d'offres », après « Veille du marché ». Quatre tuiles
(dernier passage réussi + version ; portail → app ; DCE gardés / échecs ; bordereaux trouvés · sans
prix · raisons classées), le voyant, puis le tableau des passages sur 30 jours — **écarts et
erreurs nommés, dépliables**.

| Voyant | Règle |
| --- | --- |
| **vert** | un passage `ok` de chaque module ACTIF dans les 6 dernières heures **et** aucun écart sur le dernier |
| **orange** | dernier passage entre 6 et 24 h, ou `partiel` |
| **rouge** | > 24 h, `echec`, ou écart non vide — **avec la raison en clair** |
| **gris** | aucun passage reçu |

« Module ACTIF » veut dire : un module dont un passage a déjà été reçu. Le socle n'invente pas une
moissonneuse à quatre modules chez une installation qui n'en fait tourner qu'un.

Le **même voyant, en petit, sur le tableau de bord** (`VoyantVeilleWidget`, en tête) avec le
libellé du dernier passage — parce que personne n'ouvre une page de supervision tous les matins.

**Base vide : « Aucun passage reçu », en gris. Jamais une erreur.**

## §E — Ce qui ne change pas

Cockpit, clôturés, étoilés, à préparer, soumissionnés, marchés, projets, cautions, export PDF :
inchangés. La fiche imprimable et `FicheAoHtml` reçoivent les blocs **Lots / Articles / Compte
rendu** quand ils existent (les `<details>` s'impriment dépliés).

`veille:ecarter-irrecuperables` gagne une **quatrième garde** : une fiche dont le `compte_rendu`
explique l'échec n'est pas une fiche perdue, c'est une fiche **documentée**. La masquer effacerait
précisément l'information qu'on venait de gagner ; elle reste visible, avec sa raison, dans la
colonne « Lecture ».

`FicheDetailPortail::lirePour()` lit la colonne `texte_portail` d'abord, **les notes en repli** —
aucun stock n'est migré, et une fiche d'avant la v4 affiche son panneau exactement comme avant.

---

## Fichiers touchés

| Fichier | Volet |
| --- | --- |
| `database/migrations/2026_09_09_100000_add_typologie_v4_to_tenders_table.php` *(neuf)* | §A1 |
| `database/migrations/2026_09_09_100010_create_tender_lots_table.php` *(neuf)* | §A2 |
| `database/migrations/2026_09_09_100020_add_lot_et_origine_to_tender_bordereau_lines_table.php` *(neuf)* | §A3 |
| `database/migrations/2026_09_09_100030_rattacher_les_lignes_de_bordereau_au_lot_zero.php` *(neuf)* | §A3 |
| `database/migrations/2026_09_09_100040_create_veille_passages_table.php` *(neuf)* | §A4 |
| `app/Enums/SourceAo.php`, `app/Enums/FormePrix.php` *(neufs)* | §A1 |
| `app/Models/TenderLot.php`, `app/Models/VeillePassage.php` *(neufs)* | §A2, §A4 |
| `app/Models/Tender.php` | §A1 (colonnes, casts, `auditExclude`), §A2 (`lots()`, `lotUnique()`, `estAlloti()`, `formePrix()`) |
| `app/Models/TenderBordereauLine.php` | §A3 (origines, `lot()`, `estManuelle()`) |
| `app/Services/Tenders/TypologieAoService.php` *(neuf)* | §B2 — LA table de vérité |
| `app/Services/Tenders/LotsAoService.php` *(neuf)* | §B4, §D1 (`partDuLot()`) |
| `app/Services/Tenders/VeilleImportService.php` | §B (`importerV2()` et ses onze auxiliaires ; `identique()` déplie un enum) |
| `app/Http/Controllers/Api/VeilleIngestController.php` | §B (aiguillage `contrat: 2`, validation stricte) |
| `app/Http/Controllers/Api/VeillePassageController.php`, `VeilleEtatController.php` *(neufs)* | §B2 |
| `routes/api.php` | §B2 (deux routes) |
| `docs/contrats/INGEST-V2.md` *(neuf)* | §B |
| `tests/Fixtures/IngestV2/*.json` *(trois, neufs)* | §B |
| `app/Http/Controllers/Api/VeilleAApprofondirController.php` | §B3 |
| `app/Support/FicheDetailPortail.php` | §A1 (`lirePour()` / `presentPour()`) |
| `app/Services/Tenders/PurgeVeilleService.php` *(neuf)* | §C |
| `app/Console/Commands/Tenders/PurgerVeille.php` *(neuf)* | §C |
| `app/Services/Tenders/LectureAoService.php` *(neuf)* | §D1 (colonne « Lecture », synthèse, raison classée) |
| `app/Filament/Support/ColonneSourceType.php` *(neuf)* | §D1 |
| `app/Filament/Admin/Resources/Tenders/Tables/FiltresAo.php` | §D1 (5 filtres neufs ; Famille et Montant regardent les lots) |
| `app/Filament/Admin/Resources/Veille/Tables/VeilleTable.php` | §D1 |
| `app/Services/Tenders/FicheAoService.php` | §D2/§D3 (badges, annulation, lots, articles, compte rendu, type, résultat BDC, taux de rabais) |
| `app/Support/FicheAoHtml.php` | §D2/§D3 (lots, articles, compte rendu, candidature, enchère, résultat BDC, « sans objet ») |
| `app/Filament/Admin/Resources/Tenders/Schemas/FicheAo.php` | §D2/§D3 (six sections neuves, action « Taux de rabais ») |
| `app/Filament/Admin/Resources/Tenders/Schemas/SectionsAo.php` | §D3 (règlement « sans objet »), §A1 (`lirePour`) |
| `app/Livewire/LotsAo.php`, `resources/views/livewire/lots-ao.blade.php` *(neufs)* | §D2 |
| `app/Services/Tenders/EtatVeilleService.php` *(neuf)* | §D4 — LE voyant |
| `app/Filament/Admin/Pages/EtatDeLaVeille.php` + sa vue *(neufs)* | §D4 |
| `app/Filament/Admin/Widgets/VoyantVeilleWidget.php` + sa vue *(neufs)* | §D4 |
| `public/css/socle-dense.css`, `resources/views/fiche-ao/imprimable.blade.php` | §D, §E |
| `app/Services/Tenders/CycleDeVieAoService.php` | §E (quatrième garde) |

### Tests

| Fichier | Ce qu'il prouve |
| --- | --- |
| `tests/Unit/Services/TypologieAoServiceTest.php` *(neuf)* | Chaque code → sa forme de prix (29 types + inconnu + null), la primauté de la forme envoyée, le libellé « au rabais », les hors métier, les catégories |
| `tests/Feature/Veille/IngestionV2Test.php` *(neuf)* | Les 3 fixtures, `remplacer` (machine remplacée / main intacte), lots et lignes remplacées, articles → lot 0, réponse détaillée, 422 sur champ fautif et sur plafond, **v1 inchangée**, porte fermée par défaut |
| `tests/Feature/Veille/PurgeVeilleTest.php` *(neuf)* | Essai à blanc n'écrit rien (journal compris), `--confirmer` supprime exactement les cibles avec leurs dépendances, projet / caution / facture / Drive survivent, `--source=`, base vide |
| `tests/Feature/Veille/EtatDeLaVeilleTest.php` *(neuf)* | Ouverture puis fermeture d'un passage par le même `id`, plafond des erreurs, voyant vert / orange / rouge / gris, page, widget, et `GET /api/veille/etat` **égal** à l'écran |
| `tests/Feature/Veille/EcransV4Test.php` *(neuf)* | Badges et fourchette de la liste, colonne « Lecture » jamais vide, les 5 filtres, masques par défaut, Famille et Montant sur les lots, lots de la fiche, décision à la volée, « sans objet » par type, compte rendu, bandeau d'annulation, imprimable, file de rattrapage, §E |
| `tests/Feature/Veille/MigrationsV4Test.php` *(neuf)* | Colonnes et tables présentes, migrations **rejouées deux fois**, base vide inerte, `source` rétro-remplie sans rien inventer, rattachement au lot 0 idempotent, unicité (`tender_id`, `numero`) |

**Aucun test existant n'a été modifié ni supprimé.** C'est la preuve la plus directe que la v1 du
contrat, la fiche v3.32 et les filtres v3.37 sont intacts.

### Le compte

| | |
| --- | --- |
| Tests neufs de ce lot | **99** (38 + 16 + 10 + 12 + 17 + 6) |
| Suite complète | **2 733 passés, 1 ignoré, 10 612 assertions** — sur base vide (SQLite en mémoire), comme RUWAD |
| Pint | **zéro écart** |
| Migrations | 5, rejouées deux fois sans erreur |
| Dépendances composer ajoutées | **0** |

---

## Écarts assumés au cahier, et leur raison

1. **La lecture du libellé « au rabais » passe APRÈS le code de procédure** (§B2). Le cahier
   l'énonce en dernier ; le placer avant aurait fait d'un AMI dont le libellé porte le mot un
   marché à bordereau. Le code du portail tranche d'abord ; le libellé n'est lu que là où le code
   aurait dit « sur offre de prix ».
2. **La purge ne « refuse pas de tourner » sur une fiche liée** (§C) : elle l'exclut de la cible et
   la liste. Le cahier dit les deux dans la même phrase ; la seconde lecture est la seule qui
   permette au gérant de purger sa veille.
3. **La file `bordereau=manquant` exclut aussi les fiches dont un LOT porte des lignes lues**
   (§B3). Le cahier ne le prévoyait pas — il précède l'allotissement. Sans cet ajout, tout AO
   alloti correctement lu serait re-servi indéfiniment, puisque son bordereau ne vit plus dans
   `fiche_ia` mais dans ses lots.
4. **Le bloc « Lots » est un composant Livewire**, pas du HTML statique (§D2), parce que le cahier
   exige un select **à sauvegarde immédiate**. C'est le seul élément interactif de la carte.
5. **`lots_count` et `annule` ne sont pas nullables** mais portent leurs défauts (0 et false), que
   le cahier spécifie explicitement. Une colonne booléenne nullable aurait rendu tout `where`
   ambigu.
6. **Le champ « lieu » du contrat n'a pas de colonne** : il est accepté, validé, et vit dans
   `texte_portail` et la fiche portail, comme le cahier le prévoit (« dans `texte_portail` et
   l'en-tête ») — pas de migration pour un champ que le panneau du portail porte déjà.
7. **La taille des zips est affichée en unités décimales** (10⁶) et non binaires : c'est ainsi que
   le VPS l'annonce et que la maquette l'écrit (4 200 000 octets = « 4,2 Mo »).

---

## Comment vérifier à l'écran

1. **La liste** — `/veille` : chaque ligne porte « Marché » ou « Bon de commande », un type abrégé
   et une forme de prix ; la colonne **« Lecture »** n'est jamais vide. Un AO alloti montre un badge
   violet « N lots » et une estimation en fourchette.
2. **Les filtres** — dérouler « Source » : cocher « Bons de commande » ne laisse que les avis
   d'achat. « Type de procédure » propose les 29 types. Par défaut, **aucun concours ni aucune
   annulée** n'apparaît, mais **les AMI sont là** ; décocher les masques les rappelle.
3. **Les lots** — ouvrir la fiche d'un AO alloti : le bloc « Lots » est **juste après l'en-tête**,
   la colonne Décision est un menu déroulant, et changer sa valeur affiche aussitôt une notification
   (recharger la page : la valeur est restée). Le bordereau du lot 1 est ouvert, les autres repliés.
4. **Le compte rendu** — sur une fiche livrée par la moissonneuse v4 : la section « Compte rendu de
   lecture » liste les pièces avec leur type réel et leur lecteur ; une pièce non lue porte sa
   raison en orange, et un bordereau manquant est annoncé en tête de bloc.
5. **Un bon de commande** — la fiche montre « Articles (portail) » à la place du bordereau, les
   spécifications se déplient au clic, et « Caution provisoire » comme « Méthode du règlement »
   disent **« Sans objet (bon de commande) »**.
6. **Un AMI** — « Bordereau des prix » dit « Sans objet pour ce type : dossier de candidature, pas
   de prix à ce stade », et le bloc « Dossier de candidature » liste les pièces lues.
7. **L'état de la veille** — `/etat-veille` : sur une installation qui n'a pas encore de
   moissonneuse v4, la page affiche « Aucun passage reçu » en gris, **sans erreur**. Le tableau de
   bord porte le même voyant, en tête.
8. **La purge** — lancer `php artisan veille:purger` **sans option** : le tableau des quatre
   groupes s'affiche, la ligne « Essai à blanc : RIEN n'a été supprimé » le confirme, et
   `storage/logs/` ne contient aucun fichier `purge-veille-*`. **Ne pas lancer `--confirmer` sans
   sauvegarde de la base.**
9. **La moissonneuse** — appeler `GET /api/veille/etat` avec le jeton : le voyant qu'elle rend est
   exactement celui de l'écran.
