# Contrat d'ingestion v2 — `POST /api/veille/ingest`

**Version 2 du contrat, livrée avec le socle v4.0.0.** C'est la frontière entre la moissonneuse
(qui vit sur le VPS, hors de ce dépôt) et l'application. Ce document fait foi des deux côtés.

> **La v1 reste acceptée telle quelle.** Un envoi sans `"contrat": 2` suit exactement le chemin de
> la v3.18 → v3.37 : même route, même jeton, même réponse. Rien de ce qui tourne aujourd'hui ne
> doit être modifié pour que la v4 fonctionne.

---

## 1. Ce qui change, et pourquoi

Jusqu'à la v1, la moissonneuse livrait **du texte à découper** : le rendu d'une page, l'OCR d'un
avis, celui d'un dossier. L'application découpait, devinait, et rangeait ce qu'elle avait compris.

La v2 livre **une lecture déjà faite** :

- les champs du portail arrivent **typés** — code de procédure, forme de prix, catégorie,
  allotissement, annulation — au lieu d'être extraits d'un bloc de texte ;
- les **lots** arrivent avec leur bordereau, chacun le sien ;
- les **articles** d'un avis d'achat arrivent structurés : le portail les publie ainsi, aucun PDF
  n'est à lire ;
- un **compte rendu de lecture** dit ce que le VPS a ouvert, ce qu'il a lu, et **pourquoi** quand
  il a échoué. C'est ce qui rend la chaîne vérifiable.

---

## 2. Authentification, route, limites

| | |
| --- | --- |
| Route | `POST /api/veille/ingest` — **la même qu'en v1** |
| En-tête | `Authorization: Bearer <VEILLE_INGEST_TOKEN>` |
| Sans jeton configuré côté app | `503` — la porte n'existe pas (fermée par défaut) |
| Jeton absent ou faux | `401` |
| Corps invalide | `422`, avec **le champ fautif** dans `details` |
| Erreur interne | `500` — le détail part au journal, jamais dans la réponse |
| Fréquence | limiteur `veille-ingest`, comme la v1 |

**Plafonds (§B8).** Un dépassement est refusé en `422`, jamais tronqué en silence :

| Champ | Plafond |
| --- | --- |
| `texte_portail` | 200 000 caractères |
| `lots` | 200 |
| `lots[].bordereau` et `articles` | 500 lignes chacun |
| `pieces` | 100 |

**Tout champ inconnu du contrat est ignoré sans erreur.** Une moissonneuse en avance sur l'app ne
casse rien.

---

## 3. Le corps, champ par champ

```jsonc
{
  "contrat": 2,                    // OBLIGATOIRE : c'est lui qui aiguille vers la v2
  "moissonneuse": "4.0",           // signature de version, estampillée sur la photo du dossier
  "remplacer": true,               // cf. §4
  "source": "consultation",        // "consultation" | "bdc" — OBLIGATOIRE

  // --- IDENTITÉ (§B1) ---
  "ref_consultation": "1035512",   // obligatoire si source = consultation
  "org_acronyme": "z7x",
  "bdc_id": "377696",              // obligatoire si source = bdc
  "pmmp_url": "https://www.marchespublics.gov.ma/…",

  // --- CE QUE LE PORTAIL ÉCRIT (§B2) ---
  "portail": {
    "reference": "12/2026/DR4",
    "objet": "…",
    "acheteur": "…",
    "procedure_code": 1,           // cf. §6 — le code du portail, jamais deviné
    "procedure_libelle": "Appel d'offres ouvert",
    "forme_prix": "offre_prix",    // offre_prix | rabais | enchere | sans_prix | devis
    "categorie": "fournitures",    // travaux | fournitures | services
    "date_mise_en_ligne": "2026-08-29",
    "date_limite": "2026-09-15",
    "heure_limite": "10:00",
    "date_limite_devis": "2026-09-14T15:30:00",   // bons de commande
    "lieu": "…",
    "estimation": 780000.00,
    "caution": 13000.00,
    "reserve_pme": false,
    "domaines": "…", "qualifications": "…", "agrements": "…",
    "allotissement": 3,
    "annule": false, "motif_annulation": null, "date_annulation": null,
    "nature_bdc_code": null, "nature_bdc_libelle": null
  },

  // --- LE TEXTE COMPLET DE LA FICHE DÉTAIL ---
  "texte_portail": "… texte complet de la fiche détail …",

  // --- LES LOTS ET LEURS BORDEREAUX (§B4) ---
  "lots": [
    { "numero": 1, "intitule": "…", "estimation": 120000.00, "caution": 2000.00,
      "famille": "fournitures-equipements",
      "familles": [ { "famille": "fournitures-equipements", "part": 100 } ],
      "forme_prix": "offre_prix",
      "resultat": { "attributaire": "…", "montant": 118000.00, "infructueux": false, "publie_le": "2026-10-02" },
      "bordereau": [
        { "n": "1", "designation": "…", "unite": "U", "quantite": "12", "pu_estime": null, "montant": null }
      ] }
  ],

  // --- LES ARTICLES D'UN AVIS D'ACHAT (§B4) ---
  "articles": [
    { "n": "1", "designation": "…", "specs": "…", "unite": "F", "quantite": "1", "tva": "20", "garanties": "-" }
  ],

  // --- LE COMPTE RENDU DE LECTURE (§B5) ---
  "pieces": [
    { "nom": "CPS AO 12-2026.pdf", "type": "pdf_scanne", "pages": 28, "lecteur": "ocr",
      "lu": true, "resultat": "lots 1-3 · 3 bordereaux (p. 22, 24, 27)", "raison": null }
  ],
  "compte_rendu": {
    "pieces_presentes": 5, "pieces_lues": 5,
    "bordereau": "trouve",          // trouve | manquant | sans_objet
    "raison": null,                 // OBLIGATOIRE en clair quand bordereau = "manquant"
    "zip": "corpus/consultations/dce/1035512.zip",
    "zip_octets": 4200000,
    "lu_le": "2026-09-02T16:40:00Z"
  },

  // --- LA LECTURE DE L'IA LOCALE (contrat v3.32.0 §B, inchangé) ---
  "fiche_ia": { },
  "familles_ia": [ ],
  "mode_passation_ia": "…",

  // --- LES TEXTES OCÉRISÉS (chemin v1, inchangé : rangés dans les notes) ---
  "dossier": "…", "avis": "…", "suivi": "…",

  // --- LE RÉSULTAT D'UN AVIS D'ACHAT (§B6) ---
  "resultat": { "nb_devis": 24, "attributaire": "…", "montant_ttc": 38040.00, "publie_le": "2026-09-02" },

  "date_publication": "2026-08-29"
}
```

---

## 4. `remplacer` — ce que la machine remplace, ce qu'elle ne touche jamais

`"remplacer": true` dit : **ceci est une photo du dossier, pas un cumul de passes.** Une pièce
disparue du portail doit disparaître de la fiche.

**Remplacé** (champs MACHINE) :

- toutes les colonnes issues de `portail`, `texte_portail` ;
- `fiche_ia`, `familles_ia`, `mode_passation_ia` ;
- les **lots**, et les lignes de bordereau d'origine `lecteur` (dossier) ou `portail` (articles) ;
- `pieces`, `compte_rendu`, `corpus_zip`, `resultat_bdc`.

**Jamais touché** (saisies HUMAINES), même en mode `remplacer`, sans exception :

- `favori` (l'étoile), `participation`, `status` ;
- les **notes humaines** — le socle n'écrit que sous ses propres séparateurs `=====` ;
- `drive_link`, `our_offer` ;
- les lignes de bordereau d'origine **`manuel`** ;
- la **décision** et les **notes** d'un lot ;
- la checklist et les documents de préparation ;
- tout champ marqué « saisi à la main » (objet, date limite, acheteur — règle v3.27.0 §C).

**Sans `remplacer`** : la règle du socle, mot pour mot — *on complète, on n'écrase pas*. Seuls les
champs vides sont remplis, plus les valeurs provisoires posées par le socle lui-même (une référence
`PMMP-…`, un objet resté à l'état de bloc source).

---

## 5. Les règles d'ingestion, dans l'ordre où elles s'appliquent

1. **Identité.** `consultation` → dédoublonnage sur (`ref_consultation`, `org_acronyme`), comme
   depuis la v3.18. `bdc` → dédoublonnage sur `bdc_id`. Les deux modules du portail ont des
   identifiants qui ne se ressemblent pas : les confondre écrirait un avis d'achat sur une
   consultation. Une fiche créée ici porte `source` et `participation = veille` ; sur une fiche
   existante, `status` ne bouge pas.
2. **Champs portail → colonnes typées.** Une valeur absente n'efface rien : le portail qui se tait
   ne dit pas « vide ». Pour un bon de commande, `deposit_date` / `deposit_time` **sont** la date
   et l'heure limites de remise des devis.
3. **Forme de prix.** Ce que vous envoyez prime toujours. À défaut, elle se déduit du code de
   procédure (§6). C'est la même table des deux côtés de l'écran : la file de rattrapage et
   l'affichage ne peuvent pas diverger.
4. **Lots.** Chaque lot est créé ou mis à jour **par son numéro**, et ses lignes d'origine
   `lecteur` sont remplacées. Un envoi **sans** `lots` mais avec un bordereau dans `fiche_ia`
   alimente le **lot 0** — le lot unique et invisible d'un AO non alloti. `lots_count` = nombre de
   lots de numéro ≥ 1. Les **articles** d'un bon de commande deviennent les lignes du lot 0, avec
   `origine = portail`.
5. **`compte_rendu` et `pieces`** sont stockés tels quels ; `corpus_zip` et `corpus_zip_octets`
   sont extraits de `compte_rendu.zip` / `.zip_octets`.
6. **`resultat`** d'un bon de commande → `resultat_bdc`. Pour une **consultation**, le PV et
   l'attribution continuent de passer par le circuit existant (`suivi`, `clotures-a-enrichir`),
   qui sait lire un tableau de commission et trancher entre plusieurs pistes.
7. **La réponse dit ce qui a été RETENU** (§7). Aucun échec silencieux.
8. **Validation** : plafonds du §2, champs inconnus ignorés, `422` avec le champ fautif.

---

## 6. Les codes de procédure et la forme de prix qui en découle

| Codes | Type | Forme de prix déduite |
| --- | --- | --- |
| 1, 50, 2 | AOO · AOO simplifié · AO restreint | `offre_prix` |
| 35, 43, 54, 55, 57, 59 | Phases 2 et 3 (présélection, négocié, dialogue, PPP) | `offre_prix` |
| 60, 61 | Demande de cotation — BMD | `offre_prix` |
| 9 | Marché négocié sans publicité préalable | `offre_prix` |
| 34, 42, 53, 56, 58 | **Phases 1** — on sélectionne des candidats | `sans_prix` |
| 37 | Appel à manifestation d'intérêt | `sans_prix` |
| 4, 40, 47, 39, 51, 52, 44, 45, 46 | Concours & consultations architecturales | `sans_prix` |
| 38 | Enchère électronique inversée | `enchere` |
| — | `source = bdc` | `devis` |
| autre / inconnu | (le libellé reste affiché tel quel) | `offre_prix`, ou `rabais` si le libellé porte « rabais » ou « majoration » |

**Ce que la forme de prix commande.** `sans_prix`, `enchere` et `devis` **n'attendent aucun
bordereau** : ces fiches ne sont jamais servies par `GET /api/veille/a-approfondir?bordereau=manquant`,
et l'écran affiche « sans objet pour ce type » au lieu d'un vide. C'est la portée exacte de la règle
du gérant — *« il n'existe aucun AO sans bordereau de prix »* vaut pour les marchés **chiffrés**.

Une consultation **annulée** (`portail.annule = true`) sort elle aussi de cette file : il n'y a
plus rien à préparer.

---

## 7. La réponse

```json
{
  "ok": true,
  "contrat": 2,
  "statut": "cree",
  "tender_id": 1841,
  "source": "consultation",
  "lots": 3,
  "lignes": 7,
  "articles": 0,
  "remplace": true,
  "champs_portail": 12
}
```

`201` à la création, `200` sur une fiche existante. `statut` vaut `cree` ou `complete`.
`champs_portail` est le nombre de colonnes **réellement changées** : réécrire à l'identique ne
compte pas, et c'est ainsi qu'une passe de re-visite se distingue d'une passe qui apporte.

---

## 8. `POST /api/veille/passage` — le battement de cœur

Même jeton, même limiteur. La moissonneuse **ouvre** un passage en début de course, puis renvoie le
**même `id`** pour le fermer avec ses compteurs.

```json
{
  "id": 412,
  "module": "consultations",
  "version": "4.0",
  "demarre_le": "2026-09-02T16:40:00Z",
  "fini_le": "2026-09-02T17:11:00Z",
  "statut": "ok",
  "portail_lignes": 37,
  "fiches_creees": 12, "fiches_maj": 25,
  "dce_gardes": 37, "dce_echecs": 0,
  "bordereaux_trouves": 31, "sans_prix": 4,
  "raisons": { "scan illisible (zip gardé)": 2 },
  "erreurs": [ "1035990 : fiche détail introuvable (re-tentée)" ],
  "ecarts": [ { "ref": "1035990", "raison": "fiche détail introuvable" } ],
  "disque_libre_go": 14.0,
  "duree_s": 1860
}
```

- `module` : `consultations` | `bdc` | `resultats` | `collecte` ;
- `statut` : `en_cours` | `ok` | `partiel` | `echec` ;
- `erreurs` est plafonnée à **200** entrées ;
- une mise à jour ne remet à zéro aucun compteur qu'elle ne porte pas.

Réponse : `{ "ok": true, "id": 412, "module": "…", "statut": "…" }` — `201` à l'ouverture,
`200` à la fermeture.

**`GET /api/veille/etat`** rend le dernier passage de chaque module et le voyant global, calculés
par la même règle que l'écran :

- **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** — plus de 24 h, `echec`, ou écart non vide — avec la raison en clair ;
- **gris** — aucun passage reçu. Ce n'est pas une anomalie : c'est une installation neuve.

---

## 9. Exemples complets

Trois envois réels, un par cas, dans le dépôt — ce sont les **fixtures des tests**, donc ils ne
peuvent pas se périmer sans que la suite le dise :

| Cas | Fichier |
| --- | --- |
| Marché à 3 lots | `tests/Fixtures/IngestV2/marche-3-lots.json` |
| Bon de commande à 4 articles | `tests/Fixtures/IngestV2/bon-de-commande-4-articles.json` |
| Appel à manifestation d'intérêt | `tests/Fixtures/IngestV2/ami-sans-prix.json` |

Aucune donnée réelle n'y figure : ni société, ni montant, ni référence d'une installation.
