Accès API Odoo NEOSEN pour Goralab (lecture seule ventes)


Version 2.2 du 09/09/2026 : ajout du flux sessions d'examen (dates, clotures, places) et de la section attribution marketing (UTM). Version 2.0 testée de bout en bout (XML-RPC, JSON-RPC, et tir réel des 2 webhooks). Contact technique : direction NEOSEN.


1. Ce que cet accès permet (et ses limites, à lire avant de coder)


  • Lecture seule des commandes de vente (sale.order), de leurs lignes (sale.order.line), du catalogue produits (product.template, product.product) et des devises (res.currency).
  • Deux webhooks temps réel : vente confirmée et paiement reçu.

  • Limites importantes (par conception, périmètre « ventes seules ») :

  • Le nom du client est disponible (embarqué dans la commande), mais pas ses coordonnées : email, téléphone, adresse ne sont pas accessibles.
  • Les factures (account.move) ne sont pas lisibles en API. Le webhook paiement vous notifie, mais vous ne pouvez pas re-lire la facture ; vous rebasculez sur la commande via le champ invoice_origin (voir §6).
  • Aucune écriture, aucun accès CRM, RH, comptabilité. Les tentatives renvoient une erreur d'accès (normal).
  • Le fil de discussion des commandes (mail.message, chatter) n'est pas accessible : il contient des échanges nominatifs avec les clients, hors périmètre.
  • L'attribution marketing (UTM) est accessible et ne contient aucune donnée personnelle : voir §5 bis.
  • Les sessions d'examen (dates, clôtures, places restantes par ville) sont fournies par un flux JSON dédié, pas par l'API Odoo : voir §5 ter.

  • 2. Connexion


    ParamètreValeur
    URL`https://www.digimoov.fr`
    Base de données`marouane177-odoo-17-production-19137003`
    Login`goralab-api@digimoov.fr`
    Mot de passela clé API (transmise séparément par canal sécurisé, jamais par email)

    Protocoles : XML-RPC (/xmlrpc/2/) ou JSON-RPC (/jsonrpc). Les deux sont testés et fonctionnels. La clé API s'utilise à la place du mot de passe.


    3. Exemple XML-RPC (Python)


    
    import xmlrpc.client
    
    URL = "https://www.digimoov.fr"
    DB = "marouane177-odoo-17-production-19137003"
    LOGIN = "goralab-api@digimoov.fr"
    API_KEY = "..."  # transmise séparément
    
    common = xmlrpc.client.ServerProxy(f"{URL}/xmlrpc/2/common")
    uid = common.authenticate(DB, LOGIN, API_KEY, {})   # entier stable, cacheable
    models = xmlrpc.client.ServerProxy(f"{URL}/xmlrpc/2/object")
    
    # Ventes confirmées depuis une date (curseur incrémental sur write_date)
    orders = models.execute_kw(DB, uid, API_KEY, "sale.order", "search_read",
        [[["state", "=", "sale"], ["write_date", ">=", "2026-09-01 00:00:00"]]],
        {"fields": ["name", "date_order", "state", "amount_untaxed", "amount_total",
                    "currency_id", "partner_id", "company_id", "order_line", "write_date"],
         "limit": 200, "order": "write_date asc"})
    
    # Lignes d'une commande
    lines = models.execute_kw(DB, uid, API_KEY, "sale.order.line", "read",
        [orders[0]["order_line"]],
        {"fields": ["product_id", "name", "product_uom_qty", "price_unit", "price_total"]})
    

    En search_read/read, les champs relationnels reviennent en paire [id, libellé] :

    partner_id = [88298, "MIMMIH Nabil"], currency_id = [1, "EUR"], company_id = [2, "DIGIMOOV"], order_line = [id1, id2, ...].

    → C'est ainsi que vous obtenez le nom du client (même si l'accès direct au partenaire est bloqué).


    4. Exemple JSON-RPC (curl)


    
    curl -s https://www.digimoov.fr/jsonrpc -H 'Content-Type: application/json' -d '{
      "jsonrpc": "2.0", "method": "call",
      "params": {"service": "object", "method": "execute_kw",
        "args": ["marouane177-odoo-17-production-19137003", UID, "API_KEY",
          "sale.order", "search_read",
          [[["state", "=", "sale"]]],
          {"fields": ["name", "date_order", "amount_total"], "limit": 10}]}}'
    

    UID = l'entier renvoyé par common.authenticate (stable, cacheable).


    5. Champs utiles


    sale.order : name, date_order, state (draft/sent/sale/cancel), amount_untaxed, amount_total, currency_id, partner_id, company_id, order_line, write_date.


    sale.order.line : order_id, product_id, name, product_uom_qty, price_unit, price_subtotal, price_total.


  • Dates : format AAAA-MM-JJ HH:MM:SS en UTC (pas d'heure locale : convertissez côté Goralab).
  • Montants : exprimés dans la devise de la commande (currency_id). Ne supposez pas EUR partout : lisez toujours currency_id.
  • Société : company_id : 1 = MCM ACADEMY, 2 = DIGIMOOV.
  • Volumétrie : ~44 000 commandes. Utilisez limit/offset et un curseur incrémental sur write_date (jamais un export complet répété).

  • 5 bis. Attribution marketing (UTM)


    Ces champs sont déjà accessibles avec votre clé, sans aucune donnée personnelle. Vous n'avez rien à demander de plus.


    Sur sale.order :


    ChampTypeContenu
    `campaign_id`many2one`[id, "nom_de_campagne"]`
    `medium_id`many2one`[id, "cpc"]`, `[id, "email"]`…
    `source_id`many2one`[id, "google"]`, `[id, "facebook"]`…
    `conversion_utm_campaign` / `_medium` / `_source`textechamps bruts d'un module interne, rarement remplis (~130 commandes)

    Les référentiels sont lisibles directement : utm.campaign (349 enregistrements), utm.medium (36), utm.source (1 211), champ name.


    Taux de couverture, à intégrer dans vos calculs : sur ~43 900 commandes, seules 5 750 portent une campagne et 6 181 une source, soit environ 13 %. Les 87 % restants n'ont pas d'attribution en base : ne les comptez pas comme « direct », comptez-les comme « inconnu ».


    Qualité des données : le référentiel contient des valeurs polluées par des gabarits de tracking mal substitués ({campaign}, %7Bmedium%7D, 1). Filtrez-les avant analyse plutôt que de les traiter comme de vraies campagnes.


    Exemple, lecture des commandes attribuées :


    
    cmds = ex('sale.order', 'search_read',
              [[('campaign_id', '!=', False), ('date_order', '>=', '2026-01-01 00:00:00')]],
              {'fields': ['name', 'date_order', 'amount_total', 'currency_id', 'state',
                          'campaign_id', 'medium_id', 'source_id'], 'limit': 500})
    

    Exemple, agrégation directe côté Odoo (plus rapide qu'un export, et strictement anonyme) :


    
    ca = ex('sale.order', 'read_group',
            [[('campaign_id', '!=', False), ('state', 'in', ['sale', 'done'])]],
            {'fields': ['amount_total'], 'groupby': ['campaign_id'], 'orderby': 'amount_total desc'})
    # -> [{'campaign_id': [313, '19965112228'], 'campaign_id_count': 53, 'amount_total': 39360.0}, ...]
    

    Piège webhook : comme tous les champs relationnels, le webhook renvoie un entier nu (campaign_id: 387), pas [id, nom]. Re-fetchez le nom via utm.campaign / utm.medium / utm.source, qui vous sont ouverts en lecture.


    5 ter. Flux sessions d'examen : dates, clôtures et places restantes


    Ces données ne passent pas par l'API Odoo. Le modèle des sessions contient des données candidats et des identifiants techniques, donc nous ne l'ouvrons pas. À la place, nous publions un flux JSON qui ne contient que ville, dates et places : ni candidat, ni jury, ni surveillant, ni identifiant.


    Flux`GET https://goralab-feed.formalab.tech/sessions.json`
    Authentificationen-tête `Authorization: Bearer <jeton>` (ou `?token=<jeton>`)
    Supervision`GET https://goralab-feed.formalab.tech/health` — public, sans jeton
    Rafraîchissementtoutes les 30 minutes depuis Odoo
    Périmètre**Digimoov uniquement** (MCM Academy est hors périmètre), sessions à venir

    Le jeton vous est transmis par lien sécurisé à usage unique, séparément de ce document.


    
    curl -H "Authorization: Bearer $GORALAB_FEED_TOKEN" \
         https://goralab-feed.formalab.tech/sessions.json
    

    Champs de chaque session :


    ChampSens
    `session`libellé, contient la ville et la date (ex. `Lyon 27 octobre 2026`)
    `date_examen`date de l'examen
    `date_ouverture_inscriptions` / `date_cloture_inscriptions`fenêtre de commercialisation
    `jours_avant_cloture`négatif = déjà clôturée
    `inscriptions_ouvertes`booléen
    `places_max`, `places_prises`, `places_restantes`, `complet`remplissage
    `vendable`**inscriptions ouvertes ET places restantes > 0**
    `date_debut_session`, `date_fin_session`, `date_jury`jalons pédagogiques

    Règle de clôture, importante pour le pilotage des campagnes : une session ouvre 120 jours avant l'examen et ferme automatiquement un nombre de jours avant l'examen défini session par session, le plus souvent 42 ou 35 jours. Une session peut donc afficher des places libres tout en étant déjà fermée à la vente.


    Pilotez sur vendable, jamais sur places_restantes seul. Exemple réel au 09/09/2026 : Nantes 15 septembre affiche 30 places libres, mais sa clôture était le 11 août : pousser du trafic dessus, c'est brûler du budget. À la même date, 16 sessions sont réellement vendables, et Bordeaux 27 octobre clôture dans 6 jours.


    L'en-tête du flux porte genere_le, nombre_sessions, nombre_sessions_vendables et anomalies_coherence (doit rester vide ; une valeur non vide signale une incohérence entre places max, prises et disponibles côté Odoo, à nous signaler).


    6. Webhooks temps réel


    Deux notifications sortantes (POST JSON, Content-Type: application/json). Le type d'événement se lit sur le champ _model :


    Événement`_model`DéclencheurFréquence
    Vente confirmée`sale.order`la commande passe à l'état `sale`à chaque confirmation
    Paiement reçu`account.move`la facture entre en `in_payment` ou `paid`**une seule fois par facture** (pas de doublon)

    Payload réel (tel que reçu lors des tests) :


    Vente :

    
    {"_model": "sale.order", "_id": 54510, "_action": "GORALAB_vente_confirmee(#4727)",
     "id": 54510, "name": "S54510", "state": "sale",
     "date_order": "2026-09-02 16:21:08", "amount_untaxed": 100.0, "amount_total": 100.0,
     "partner_id": 88347, "company_id": 2}
    

    Paiement :

    
    {"_model": "account.move", "_id": 23881, "_action": "GORALAB_paiement_recu(#4728)",
     "id": 23881, "name": "FAD210425-02175", "payment_state": "paid",
     "invoice_date": "2026-09-02", "invoice_origin": "S54510",
     "amount_total": 100.0, "partner_id": 88347, "company_id": 2}
    

    Points à respecter impérativement (sinon « ça marche pas ») :


    1. Discriminez par _model (sale.order vs account.move). N'analysez pas _action (c'est un libellé humain, pas un type stable).

    2. Dans le webhook, les champs relationnels sont des ID entiers NUS : partner_id: 88347, company_id: 2 : pas [id, nom] comme dans search_read. Vous n'avez pas le nom dans le payload.

    3. Le webhook est une notification, pas la donnée complète. Modèle recommandé :

    - Vente : sur réception, faites read/search_read sur sale.order id=_id → vous obtenez le nom du client et le détail des lignes.

    - Paiement : account.move n'est pas lisible en API. Utilisez invoice_origin (ici "S54510") pour faire search_read sur sale.order où name = invoice_origin → vous récupérez client + montants.

    4. Paiement, valeur de payment_state : peut valoir in_payment (paiement enregistré, en attente de rapprochement bancaire) ou paid (soldé). L'événement ne part qu'une fois ; traitez-le en upsert idempotent sur name (numéro de facture).

    5. Sécurité : nous mettrons un token secret en query string (?token=...). Vérifiez-le et rejetez toute requête sans le bon token. (Odoo n'envoie pas de signature HMAC.)

    6. Pas de retry : le webhook Odoo est « send and forget », timeout 1 s, aucune reprise si votre endpoint est indisponible. Prévoyez impérativement un poll de réconciliation (ex. toutes les 15 min : search_read sur write_date >= dernier_curseur) pour rattraper les événements manqués. Le temps réel est un confort, le poll est le filet de sécurité.


    7. Ce qu'il nous faut de votre côté


    1. L'URL HTTPS de votre endpoint webhook (nous y ajoutons le token et activons les 2 automatisations, désactivées aujourd'hui).

    2. Un contact technique pour un test d'activation en réel ensemble.


    8. Règles d'usage


  • Clé nominative et révocable ; ne la partagez pas hors de votre équipe.
  • Lecture seule : toute écriture est refusée et journalisée.
  • Préférez l'incrémental au full-scan ; pas de polling sous la minute.

  • Ce document est directement exploitable par votre Claude Code : payloads réels, chemins de re-fetch et pièges connus y sont explicites.