# Rapport — v3.22.0 (notifications & cycle de vie de l'AO)

**Se livre aux DEUX installations, à l'identique. Deux migrations additives et idempotentes
(`tenders.notifications_muted`, `tenders.alertes_ao`, `users.ao_notification_preferences`), aucune
dépendance composer nouvelle, aucune suppression de fichier.**

## Le constat

Le cockpit AO savait *ranger* (v3.20.0) et *lire la concurrence* (v3.21.0). Il ne savait pas
**prévenir**. Trois conséquences, toutes vécues :

- une échéance se rate en silence — rien ne dit « il reste trois jours » ;
- un AO qu'on n'a finalement pas déposé reste « à préparer » indéfiniment : le pipeline ment, et le
  jour où quelqu'un le tranche, les taux mentent aussi ;
- une caution provisoire dort à la banque des mois après que le marché a été attribué à un autre.

Et ce que le portail publie — l'ouverture des plis, le résultat, un rectificatif — n'était visible
qu'en rouvrant la fiche à la main.

## Ce qui change, en une phrase

L'application **regarde ce qu'elle sait**, chaque nuit, et **compare ce qu'elle reçoit** à ce
qu'elle avait — puis elle le dit : par mail, dans la cloche, dans un tableau de bord et dans un
résumé périodique. Chacun règle ce qu'il veut entendre.

## §1 — Le socle de notification

`App\Enums\TypeAlerteAo` : sept types — `echeance_proche`, `echeance_manquee`, `ouverture_plis`,
`resultat`, `rectificatif`, `nouvel_ao`, `caution_a_debloquer`. C'est le **type**, et non la classe
de notification ni le canal, qui commande ce qu'un exploitant peut couper, sous quelle rubrique le
résumé range l'événement, et de quelle couleur la ligne s'affiche.

`App\Notifications\Tenders\AlerteAo` porte deux canaux et **aucune dépendance nouvelle** :

| Canal | Infrastructure empruntée |
|---|---|
| `mail` | `BrandedNotification` — gabarit de marque, pied légal, mise en file, déjà en place |
| `database` | la **cloche native de Filament**, déjà branchée sur le panneau d'administration |

La charge utile du canal `database` est au format de Filament (`getDatabaseMessage()`) : titre,
corps, icône, couleur, et une action **« Ouvrir l'AO »** qui marque la ligne lue en même temps
qu'elle y conduit. Le lien est calculé par `App\Support\LienAo`, qui désigne le panneau le temps du
calcul et choisit la bonne ressource — une **fiche de veille** ne s'ouvre pas sous « Appels
d'offres », dont la requête ne montre que nos soumissions.

`App\Services\Tenders\AlertesAoService` est le **seul** point d'émission du socle. Il porte, une
fois pour toutes, les trois règles du lot :

- **la sourdine** — un AO marqué « ne plus notifier » ne produit plus rien, d'aucun type ;
- **l'idempotence** — chaque alerte porte une CLÉ (`echeance_proche:7`, `resultat`,
  `caution_a_debloquer:12`) inscrite sur la fiche (`tenders.alertes_ao`). Elle survit à un
  redéploiement, à un vidage de cache et à une tâche rejouée trois fois dans la même nuit ;
- **les destinataires** — le personnel actif qui a `tenders.view`, plus la boîte de la gérance.

La mémoire est posée **avant** l'envoi : mieux vaut une alerte perdue si la file tombe qu'une
alerte répétée chaque nuit. Le tableau de bord, lui, montre l'état réel et ne dépend d'aucune
notification.

## §2 — Le cycle de vie (`schedule:run`, aucune dépendance externe)

`App\Services\Tenders\CycleDeVieAoService`, quatre gestes, quatre commandes :

| Commande | Heure | Ce qu'elle fait |
|---|---|---|
| `tenders:retour-en-veille` | 06:50 | un « à préparer » échu **sans dépôt** repasse en veille |
| `veille:nettoyer` | 06:55 | une veille non étoilée périmée depuis > N jours (défaut 30) est **masquée** |
| `tenders:alerter-echeances` | 07:05 | J−7, J−3, J−1 sur les AO **étoilés** non déposés |
| `tenders:alerter-cautions` | 07:10 | caution encore immobilisée sur un AO **tranché** |

L'ordre du matin n'est pas décoratif : **on transforme avant de résumer**. Le retour en veille et le
nettoyage changent ce que le résumé (07:45) aura à dire.

Deux garanties écrites dans le service :

- **le logiciel propose, l'exploitant valide** — chaque transition automatique laisse une **note
  datée** sur la fiche et se défait d'un clic (« Passer en préparation », « Réafficher ») ;
- **rien n'entre dans un indicateur** — le retour en veille en fait SORTIR un AO ; il n'en fait
  jamais entrer. La règle d'or de la v3.17.0 tient.

⚠️ Ce qui a **réellement été déposé** n'est jamais touché : un statut avancé l'exclut d'office, et
une offre chiffrée (`our_offer`) le protège même si le statut n'a pas suivi.

L'étoile est le filtre voulu pour les échéances : la moissonneuse ramasse des centaines de
consultations, et le gérant ne veut être réveillé que par celles qu'il suit.

⚠️ **`tenders:deposit-reminders` (CDC §4.11) n'est pas touchée** — elle est planifiée en production,
et le socle ne renomme jamais une commande installée. Les deux ne font pas double emploi :
l'ancienne écrit à `gerance@` par le registre du courrier sur **nos soumissions** à J−7 et J−2 ; la
nouvelle prévient **chaque exploitant**, dans sa cloche et sa boîte, sur les AO **étoilés** à
J−7 / J−3 / J−1. Si le gérant juge la première redondante, la retirer de `routes/console.php` est
une décision d'exploitation, pas une correction.

## §3 — Les événements du portail, détectés à l'ingestion

`App\Services\Tenders\EvenementsPortailService`. La mécanique tient en trois temps, et elle est
volontairement bête :

1. avant d'ingérer, on relève l'état de la fiche — existe-t-elle, les plis sont-ils ouverts, un
   attributaire est-il connu, quelle date limite, quel statut ;
2. l'ingestion fait son travail habituel (elle complète, elle n'écrase pas) ;
3. on relève à nouveau, et **la différence devient l'alerte**.

| Ce qui a changé | Alerte |
|---|---|
| `total_bidders` se remplit, ou des concurrents apparaissent | `ouverture_plis` |
| un attributaire apparaît | `resultat` |
| la date limite **connue** a bougé, ou la consultation est annulée, ou le texte porte « avis rectificatif » / « report de la date » | `rectificatif` |
| une fiche **naît** dans une famille prioritaire | `nouvel_ao` |

Aucune heuristique, aucune IA : on ne devine pas qu'un résultat est tombé, on constate qu'il n'était
pas là et qu'il y est. Le **filtre par famille** est ce qui sépare une alerte utile d'un déluge :
sans lui, prévenir de chaque consultation reviendrait à ne prévenir de rien. La liste est un
réglage (défaut : Communication, Événementiel, Audiovisuel, Formation).

`POST /api/veille/ingest` rend désormais **`alertes`** dans sa réponse — la moissonneuse le
journalise, comme `concurrents`. Elle n'a rien à changer.

⚠️ La **re-visite** elle-même est côté moissonneuse, hors socle. L'application est prête ; c'est
tout ce qui lui était demandé.

## §4 — La caution qui dort

Un AO tranché (retenu, écarté, annulé) dont une caution est encore `en_cours` et non restituée →
`caution_a_debloquer`. Une alerte **par caution**, pas par AO : un même marché peut en porter
plusieurs, et chacune se débloque séparément.

## §5 — Le résumé par mail

`tenders:resume`, chaque matin à 07:45. La tâche ne décide rien : **c'est chaque destinataire qui
porte sa cadence** — quotidienne, hebdomadaire (le défaut, donc lundi matin), ou aucune. Un
exploitant qui veut son résumé tous les jours l'obtient sans qu'on touche au cron du serveur, et son
collègue garde le lundi.

Quatre rubriques — échéances de la semaine, résultats publiés, cautions à débloquer, nouveaux AO
pertinents — lues par le **même service** que le tableau de bord : l'écran et le mail ne peuvent pas
se contredire.

**Rien à signaler → aucun mail.** Un résumé vide n'est pas un résumé : c'est ce qui apprend à
ignorer un expéditeur.

## §6 — Le tableau de bord « Cette semaine »

`/cockpit-ao`, en **tête** du groupe « Marchés & appels d'offres » (avant la Veille). Quatre cartes,
toutes cliquables jusqu'à la fiche, avec compte à rebours et signalement d'urgence sous trois jours.
Le parcours de la v3.20.0 range par étape ; cette page répond à l'autre question, celle du lundi
matin : **qu'est-ce qui demande mon attention maintenant ?**

**Base vide** : une phrase, quatre cartes vides, aucune erreur.

## §7 — Préférences et sourdine

`/mes-alertes-ao` — une page **personnelle**, pas un réglage d'installation. Par type d'alerte :

- **actif** (l'interrupteur général — éteint, le type ne part ni par mail, ni dans la cloche, ni
  dans le résumé) ;
- **par e-mail** / **dans l'application** (les canaux, séparément) ;
- **immédiat** ou **dans le résumé** — en mode « résumé », aucun mail ne part sur le moment ; le
  centre in-app, lui, reste immédiat, c'est un centre de notifications, pas une boîte aux lettres.

Plus la cadence du résumé.

Les réglages vivent dans une **colonne à part** (`users.ao_notification_preferences`) et non dans
`notification_preferences` : cette dernière est réenregistrée en bloc par l'écran de profil, et y
loger une structure imbriquée l'exposerait à être effacée. Deux écrans, deux colonnes, personne
n'écrase l'autre.

**« Ne plus notifier cet AO »** (`tenders.notifications_muted`) : l'interrupteur du dernier recours.
Il ne masque rien et ne supprime rien — la fiche reste dans les listes, les statistiques et
l'intelligence de marché. Elle cesse seulement de parler. Sans lui, le seul geste possible face à
une alerte de trop serait de couper le **type** pour tous les AO, et de rater la suivante.

Disponible à la ligne, en lot et depuis la fiche, dans la Veille, les AO soumissionnés et ⭐ AO
étoile.

## §8 — Les finitions de l'enrichissement de la veille

Trois choses que le texte reçu du portail écrit noir sur blanc et que le socle laissait passer.
Déterministe, **sans IA**, sous la règle du socle — *on complète, on n'écrase pas* :

| Champ | Ce qui est lu |
|---|---|
| `reference` | le **jeton entre la date de mise en ligne et « Objet : »** (« 25/2026/CR ») — **seulement** s'il vaut encore le provisoire `PMMP-…` |
| `caution_required` | le montant qui suit « cautionnement provisoire » / « caution provisoire » |
| `estimated_amount` | le montant qui suit « estimation » / « budget » / « montant estimé » |

Trois précautions, parce qu'un bloc mal formé ne doit rien produire plutôt que produire n'importe
quoi : il faut une **date** pour ancrer la lecture de la référence, le jeton doit être **court** et
porter au moins un **chiffre** ; et un montant trop petit est refusé — c'est un numéro de lot, une
année ou un pourcentage.

`caution_required` devient au passage un champ **importable** : un CSV qui le porte n'a plus à
passer à côté.

**`veille:rattraper` lit désormais DEUX endroits**, et c'est nécessaire : depuis la v3.19.0, le bloc
d'origine est déplacé dans les **notes** une fois l'objet découpé. Une fiche déjà rattrapée en
v3.20.0 n'a donc plus de bloc dans son objet — mais la vraie référence, le cautionnement et
l'estimation, eux, y sont toujours, et n'avaient jamais été relevés.

⚠️ L'« estimation publiée » n'est pas le **prix de marché estimé** : celui-là reste NOTRE calcul,
nourri par l'IA et la concurrence de la v3.21.0.

## La recette

- **Échéance ⭐** : trois paliers, trois alertes, **jamais deux fois** ; un AO non étoilé, un AO
  déjà déposé, un AO en sourdine → rien.
- **Retour auto en veille** : un `en_preparation` échu non déposé → `veille` + note + alerte, et
  l'opération est idempotente ; un AO **déposé** (statut avancé **ou** offre chiffrée) → inchangé.
- **Nettoyage** : une veille non étoilée échue > 30 j → `masque = true`, note posée, fiche toujours
  en base ; une veille étoilée ou récente → intacte ; le délai est bien un réglage.
- **Portail** : un re-déversement qui fait apparaître un résultat → `ouverture_plis` **et**
  `resultat` ; le repasser → plus rien ; un AO **muté** → aucune alerte, mais la concurrence est
  quand même lue ; nouvel AO d'une famille prioritaire → `nouvel_ao`, d'une famille hors cible →
  silence ; la liste des familles est un réglage.
- **Caution** : AO tranché + caution immobilisée → une alerte, une seule ; AO non tranché → rien.
- **Résumé** : **un** mail, quatre rubriques dans l'ordre ; rien à signaler → aucun mail ; hebdo
  n'envoie que le lundi, quotidien chaque jour, « aucune » jamais.
- **Tableau de bord** : les quatre rubriques, chaque ligne cliquable, compte à rebours affiché,
  urgence signalée ; un AO en sourdine en sort ; **base vide** → une phrase, pas d'erreur.
- **Centre + préférences** : l'alerte se dépose vraiment dans `notifications` et la cloche la
  relit ; le badge compte les non-lues et la lecture le décrémente ; couper le canal in-app ne
  dépose plus rien ; désactiver un type **coupe le mail ET le centre** et retire sa rubrique du
  résumé ; la page de préférences enregistre **sans toucher** aux quatre interrupteurs du profil.
- **Liens** : une alerte sur une veille conduit à `/veille/{id}`, une alerte sur une de nos offres à
  `/tenders/{id}`.
- **Hors KPI** : ni les alertes, ni le résumé, ni le tableau de bord ne changent le nombre de
  soumissions, le taux de réussite, le CA gagné ou les cautions.
- **Étanchéité** : `/cockpit-ao` et `/mes-alertes-ao` refusent un collaborateur, un investisseur, et
  un membre du personnel sans `tenders.view`.
- **Base vide (RUWAD)** : les quatre tâches, les deux écrans et les préférences répondent sans un
  seul AO ; les deux migrations sont rejouables.
- **Finitions (§8)** : la vraie référence remplace `PMMP-…` et **n'écrase jamais** une référence
  saisie ; caution et estimation extraites si présentes, laissées telles quelles sinon ;
  `veille:rattraper` les remplit sur les fiches existantes, y compris depuis les notes, et reste
  **idempotente**.
- **Non-régression** : parcours v3.20, concurrence v3.21, `/api/veille/ingest` v3.18,
  enrichissement v3.19, projection v3.16 et exports PDF répondent toujours.

Une assertion a été ajustée, et une seule : `VeilleEnrichissementTest` affirmait « rien d'IA dans ce
lot » par un `Queue::assertNothingPushed()`. La file n'est plus vide de droit — la consultation de
démonstration relève d'une famille prioritaire, sa création émet donc une alerte « nouvel AO », et
les notifications du socle sont mises en file. Le test affirme toujours la même chose — **aucun
modèle n'est appelé** — mais il l'affirme désormais sur le job d'IA (`Queue::assertNotPushed(
RunAiAnalysis::class)`) plutôt que sur la file entière.

## À faire après déploiement

1. **Réglages → « Appels d'offres — alertes et cycle de vie »** : cocher les familles de
   l'installation, régler le délai d'archivage et la cadence du résumé par défaut.
2. **Relancer une fois** `php artisan veille:rattraper` : il remplira la vraie référence, la caution
   et l'estimation sur les fiches déjà en base.
3. Ouvrir **« Cockpit AO — cette semaine »** et vérifier que les quatre cartes se remplissent.
4. Ouvrir **« Mes alertes AO »** et régler ses préférences (chaque exploitant pour lui-même).
5. Vérifier le premier matin que `schedule:run` a bien joué les cinq nouvelles tâches (le résumé
   part le lundi par défaut).

⚠️ Le premier passage de `tenders:retour-en-veille` peut faire sortir plusieurs AO anciens du
pipeline : c'est le but, et chacun porte une note qui le dit. « Passer en préparation » les ramène.
