Se rendre au contenu

Piloter Odoo en langage naturel avec Claude

Et si vous pilotiez votre ERP en écrivant simplement ce que vous voulez ? « Crée un devis de 150 € pour ce client », « Fusionne ces deux tâches en doublon », « Pourquoi le logo n'apparaît pas sur mes factures PDF ? » — cette page décrit une méthode éprouvée pour confier à Claude (Cowork et Claude Code) le pilotage quotidien d'une instance Odoo 19 Community Edition : gestion de projets, CRM, devis, facturation, contacts, multi-société. Chaque procédure présentée ici est issue d'un gabarit technique validé sur le terrain.

Architecture de collaboration

Le principe fondateur : deux instances de Claude aux rôles strictement séparés. Cowork réfléchit, rédige et lit les emails ; Claude Code, connecté au serveur en SSH, est le seul à écrire dans la base Odoo — toujours via l'ORM, jamais en SQL brut. Les deux communiquent par des fichiers de requête et de réponse déposés dans une « boîte aux lettres » partagée.

Cowork (discussion, pilotage, Gmail)
        ↕ REQ_ / RES_ via boite_aux_lettres/
Code (SSH + odoo shell — exécution BDD)
        ↕ notifications email
Odoo (instance cible)
        ↕ alias email + chatter
Gmail MCP (lecture/brouillons)

Règle absolue : Cowork = réflexion + rédaction + lecture Gmail. Code = écriture en base Odoo via SSH + odoo shell. Jamais de SQL brut en écriture.

Accéder à Odoo : odoo shell et ORM

Toutes les opérations passent par odoo shell, la console Python officielle d'Odoo, qui expose l'ORM complet (env['res.partner'], env['sale.order']…). C'est le même moteur que l'interface web : les règles de sécurité, les calculs et les notifications s'appliquent normalement.

# Odoo installé en package système (Ubuntu)
sudo -u odoo odoo shell -d VOTRE_BASE --no-http

# Odoo installé dans /opt
sudo -u odoo /opt/odoo/odoo-bin shell -d VOTRE_BASE --no-http

Vérification avant toute opération

Avant d'agir, Claude vérifie systématiquement sur quelle instance il se trouve : liste des sociétés et version d'Odoo installée.

env['res.company'].search_read([], ['id', 'name'])                                  # sociétés
env['ir.module.module'].search_read([('name','=','base')], ['installed_version'])   # version

Bon à savoir

  • Exit code 1 à la sortie d'odoo shell en CE : artefact normal — les opérations sont bien enregistrées en base.
  • OdooBot apparaît comme auteur des créations quand le contexte utilisateur n'est pas forcé. Pour forcer l'auteur : env = env(user=env.ref('base.user_admin')).
  • Jamais d'écriture SQL directe — toujours passer par l'ORM.

Exécution fiable : script par fichier + scp

Le passage de code Python inline ou en heredoc souffre de l'échappement bash (guillemets, &, antislash). La méthode robuste validée : écrire le script en local, le copier sur le serveur, puis l'exécuter en redirigeant l'entrée standard.

scp script.py ubuntu@vps.exemple.fr:/tmp/script.py
ssh ubuntu@vps.exemple.fr "sudo -u odoo odoo shell -d VOTRE_BASE --no-http < /tmp/script.py 2>/dev/null"

Capturer la sortie sans la perdre

L'artefact exit code 1 tronque la sortie lue directement. Pour un compte-rendu complet, rediriger vers un fichier puis le relire.

ssh ubuntu@vps.exemple.fr "sudo -u odoo odoo shell -d VOTRE_BASE --no-http < /tmp/script.py \
  > /tmp/out.txt 2>/tmp/err.txt; cat /tmp/out.txt; echo '=== ERR ==='; tail -20 /tmp/err.txt"

Attention : ne pas masquer stderr en aveugle (2>/dev/null) pendant le debug — une exception Python (ex. AttributeError) part dans stderr ; la capturer révèle la cause immédiatement.

Pièges de champs Odoo 19 CE

Odoo 19 a renommé ou supprimé plusieurs champs très utilisés. Connaître ces changements évite les erreurs les plus fréquentes en pilotage par script.

ChampChangement en Odoo 19 CE
res.partner.mobileSupprimé (fusionné dans phone) — AttributeError à l'accès
sale.order.line.tax_idRenommé tax_ids (many2many)
res.users.groups_idRenommé group_ids (+ all_group_ids)
product.template.uom_po_idSupprimé — ne reste que uom_id
sale.order.line.product_uomRenommé product_uom_id
team_id (équipe commerciale)Absent tant que le module Ventes/CRM n'est pas installé

Réflexe avant d'utiliser un champ : lister les candidats avec [f for f in env['modele']._fields if 'motcle' in f], ou tester 'champ' in env['modele']._fields.

Cas particulier : les noms contenant & (ex. Exemple&Cie) exigent un script Python séparé passé par fichier + scp — sinon l'échappement bash injecte un antislash parasite dans le nom stocké, à corriger ensuite via write({'name': 'Exemple&Cie'}).

Organiser le travail : projets, tâches et kanban

Propriétés de tâches — champs custom sans Studio

Odoo CE intègre nativement des champs personnalisés sur les tâches — pas besoin de l'application payante Studio. Activation : Configuration → Paramètres → « Propriétés de tâche », puis bouton « Modifier les propriétés » sur chaque tâche.

Type de propriétéUsage
SéparateurEn-tête de section (comme un frontmatter Obsidian)
TexteChamp libre
Date / Date & TempsHorodatage
BooléenCase à cocher
SélectionListe de valeurs

Les propriétés sont définies au niveau du projet et partagées par toutes ses tâches. Claude peut lire et appliquer une définition complète par script :

# Lire la définition JSON d'un projet
projet = env['project.project'].search([('name', 'ilike', 'Suivi VPS')])[0]
print(projet.task_properties_definition)

# Appliquer une définition JSON sur un projet
projet.write({'task_properties_definition': [JSON_DEFINITION]})
env.cr.commit()

Étapes kanban (stages)

En Odoo CE, les étapes kanban sont globales : elles s'associent à des projets, pas l'inverse, et une même étape peut apparaître dans plusieurs projets. Recommandation : créer des étapes distinctes par projet, même si le nom est identique.

  • Les étapes reflètent le propriétaire/gestionnaire ou le statut workflow, pas le fournisseur ou l'outil — ex. Actif [ENTITÉ] plutôt que Actif [FOURNISSEUR]. Le fournisseur va dans les propriétés custom.
  • Pastilles kanban : gris = en cours (normal) · vert = prêt / validé · orange = bloqué / en attente. Signal visuel immédiat sans bouger la carte de colonne.

Philosophie : Odoo comme un Obsidian d'entreprise

La structure des projets suit une analogie simple avec une base de notes liées :

ObsidianOdoo
Dossier projetProjet Odoo
Note d'index (nœud racine)Tâche mère
Sous-noteSous-tâche (peut être dans un autre projet)
FrontmatterPropriétés custom de tâche
[[wikilink]]Lien parent/sous-tâche cross-projet

Exemple d'organisation de référence :

ProjetRôleÉtapes
PilotageActions de gestion à traiterUrgent · À traiter · En attente · Fait
ClientsRelation client permanentePrise de contact · CDC/Devis · Actif · En pause · Archivé
InfrastructureInfrastructure clientsÀ qualifier · En cours · En production · Archivé
DéveloppementDéveloppements sur mesureArriéré · Spécifications · Dev · Tests · Délivré
Méthode/IAMéthode, agents, MCPIdée · En cours · Validé · Archivé
FormationFormationsÀ préparer · En cours · Réalisé
SupportMaintenance, supportNouveau · En cours · En attente · Résolu · Refusé
Suivi VPSInventaire VPS (asset)Commande · Actif · Chez client · Archivé
  • Pilotage = actions ponctuelles de gestion (elles se ferment).
  • Clients = relation permanente (ne se ferme jamais, elle évolue).
  • Suivi VPS = inventaire d'assets (cycle de vie propriétaire, pas workflow).
  • Sous-tâches cross-projet : tâche mère dans Clients, sous-tâches dans les projets métier.

Emails et chatter : la boucle Gmail

Odoo sait router les emails entrants vers le bon enregistrement. Combiné à un connecteur Gmail (MCP), cela permet à Claude de lire les notifications Odoo et de préparer des réponses qui atterrissent directement dans le chatter de la tâche concernée.

Le routage repose sur deux mécanismes :

  1. L'alias projet (ex. client@exemple.fr → projet Clients)
  2. Le token dans le sujet (ex. [CLT] → tâche cible)
To  : alias_projet@exemple.fr  (ex. client@exemple.fr)
Cc  : contact@exemple.fr       (catchall général)
Sujet : [TOKEN] NOM_TACHE      (ex. [CLT] NOM_CLIENT)

Ce que permet le connecteur Gmail

  • Lire les notifications Odoo reçues dans Gmail — oui.
  • Créer des brouillons de réponse (le message part ensuite au chatter Odoo) — oui.
  • Envoyer directement — non : limitation du connecteur, l'envoi reste manuel, par l'utilisateur.

Erreurs à éviter

  • Répondre (replyToMessageId) à un message en corbeille → détourne le routage.
  • Envoyer uniquement à l'adresse de notifications (ex. notifications@exemple.fr) → c'est une adresse d'envoi, pas de réception.
  • Si le message source est supprimé : créer un nouveau brouillon sans replyToMessageId.

Prérequis

  • Serveur entrant IMAP configuré (Configuration → Email entrant).
  • SPF + DKIM configurés — évite le classement en spam par Gmail.
  • Alias créés dans Odoo (Configuration → Alias de messagerie).

Multi-société sur une seule instance

Une instance Odoo CE peut héberger plusieurs sociétés — par exemple une entreprise individuelle et une SAS. Les projets sont rattachés à leur société via company_id. Claude crée une nouvelle société et y associe l'administrateur en quelques lignes :

company = env['res.company'].create({
    'name': 'NOM SAS',
    'email': 'contact@exemple.fr',
    'website': 'https://exemple.fr',
})
env.cr.commit()

# Associer l'admin à la nouvelle société
admin = env['res.users'].search([('login', '=', 'admin')])[0]
admin.write({'company_ids': [(4, company.id)]})
env.cr.commit()

Contacts : enrichir une fiche partenaire

Claude enrichit les fiches res.partner (adresse, TVA intracommunautaire, SIREN…) en croisant d'abord les informations avec une source publique — site web et mentions légales de l'entreprise — avant d'écrire quoi que ce soit.

  • Société = is_company: True — porte l'adresse, la TVA, le site, l'email et le téléphone génériques.
  • Contact = company_type: 'person', rattaché via parent_id — porte ses coordonnées propres. Il s'affiche « Société, Nom » (display_name).
p = env['res.partner'].browse(ID)
vals = {}
if not p.street: vals['street'] = '...'        # ne remplir que les champs vides
if not p.vat:    vals['vat'] = 'FR..........'  # TVA = clé(2) + SIREN(9)
if vals:
    p.write(vals); env.cr.commit()
  • Ne jamais écraser un champ déjà rempli : tester if not p.champ avant d'écrire.
  • Nom de la fiche = raison sociale ; le nom commercial / la marque va dans les Notes (comment, champ HTML), avec forme juridique et capital social.
  • Pays : env['res.country'].search([('code','=','FR')], limit=1).

Devis : sale.order en pratique

Devis en franchise de TVA

Cas courant pour un auto-entrepreneur ou une EI en franchise : un devis sans taxe. Prérequis : module sale installé.

env_ei = env(context=dict(env.context, allowed_company_ids=[COMPANY_ID]))
order = env_ei['sale.order'].create({'company_id': COMPANY_ID, 'partner_id': PARTNER_ID})
line = env_ei['sale.order.line'].create({
    'order_id': order.id,
    'product_id': PRODUCT_ID,
    'name': 'Libellé prestation',
    'product_uom_qty': 1,
    'price_unit': 150.0,
    'tax_ids': [(6, 0, [])],   # franchise TVA : forcer la liste de taxes à vide
})
env.cr.commit()
  • tax_ids: [(6, 0, [])] force l'absence de taxe. Vérifier order.amount_tax == 0 et amount_total == amount_untaxed.
  • La société émettrice est portée par company_id, indépendamment de la société qui porte les projets.
  • Signature en ligne : env['ir.config_parameter'].set_param('sale.use_signature', '1').
  • Accès portail client : fiche partenaire → Action → Accorder l'accès au portail. Vérifier qu'un res.users lié au partenaire porte base.group_portal (champ group_ids en v19).
  • Le devis naît en state = 'draft'l'envoi et la confirmation restent des actions de l'utilisateur.

Produit dédié vendu à l'unité

Préférer un produit métier réutilisable (ex. « Migration Odoo ») à un service générique facturé à l'heure :

UNITE = env.ref('uom.product_uom_unit').id      # "Unité(s)"
tmpl = env['product.template'].create({
    'name': 'Migration Odoo',
    'type': 'service',
    'invoice_policy': 'order',
    'uom_id': UNITE,            # vendu à l'unité (pas en heures)
    'list_price': 150.0,
})
prod = tmpl.product_variant_ids[0]
# Sur la ligne : product_uom_id = UoM (renommé en v19), product_uom_qty = quantité
line.write({'product_id': prod.id, 'name': 'Migration Odoo',
            'product_uom_id': UNITE, 'product_uom_qty': 1,
            'price_unit': 150.0, 'tax_ids': [(6, 0, [])]})

Lier un devis à une opportunité CRM

Prérequis : module crm installé. Le lien est porté par opportunity_id sur sale.order (réciproque : crm.lead.order_ids + bouton intelligent « Devis »).

  • Voie interface (recommandée) — devis existant : onglet « Autres informations » → champ « Opportunité ». Pour les prochains : partir de l'opportunité → bouton « Nouveau devis » → rattachement automatique, le montant remonte dans le pipeline. Réflexe métier : créer le devis depuis l'opportunité.
  • Voie script : env['sale.order'].browse(ORDER_ID).write({'opportunity_id': LEAD_ID}) puis env.cr.commit().
  • L'opportunité peut viser le contact et le devis la société (parent) : le lien fonctionne malgré tout — c'est une cohérence d'affichage à connaître.
  • expected_revenue de l'opportunité est saisi à la main et indépendant du total des devis liés.

Factures : IBAN, mentions légales, logo PDF

IBAN sur les factures

Pour qu'un IBAN figure sur les factures, il faut créer un res.partner.bank rattaché au partenaire de la société émettrice :

bank = env['res.bank'].search([('bic','=',BIC)], limit=1) or \
       env['res.bank'].create({'name': 'Nom banque', 'bic': BIC})
acc = env['res.partner.bank'].create({
    'acc_number': 'FR76 ....',           # IBAN (stocké AVEC espaces par Odoo)
    'bank_id': bank.id,
    'partner_id': company.partner_id.id, # partenaire de la société émettrice
})
acc.write({'company_id': CID})           # company_id ne "prend" pas au create → write séparé
env.cr.commit()
  • company_id à forcer en write séparé : à la création il reste souvent False (compte partagé entre toutes les sociétés). Le fixer réserve le compte à une entité.
  • Multi-entités = comptes séparés : chaque société a son propre res.partner.bank.
  • Affichage : sur une facture validée (state='posted') uniquement — invisible sur brouillon et sur devis. account.move.partner_bank_id s'auto-remplit depuis le compte société.
  • Idempotence : comparer les IBAN en normalisant les espaces (acc_number.replace(' ','')).
  • Vérification sans polluer les séquences : créer un sale.order brouillon, rendre le PDF, puis unlink() — attention, le numéro de séquence reste consommé (trou), à assumer.

Mentions légales en pied de page

Le bas des documents PDF est piloté par res.company.report_footer (champ HTML). C'est là que se placent les mentions qui n'ont pas de champ natif : capital, RCS, code APE/NAF, dates.

c = env['res.company'].browse(CID)
c.write({'report_footer':
    '

RAISON au capital de 1 000 € · SIREN ... · RCS Ville (immat. JJ/MM/AAAA) · ' 'APE 62.02A · TVA FR..
Siège : ADRESSE · ' 'contact@exemple.fr

'}) env.cr.commit()
  • Champs voisins : report_header (haut de page) et company_details (bloc adresse en en-tête).
  • Le footer est par société (multi-société) : régler celui de chaque entité.
  • Franchise de TVA (EI / auto-entrepreneur) : la mention « TVA non applicable, art. 293 B du CGI » est obligatoire → la placer dans le report_footer de l'entité concernée. Une EI n'a pas de capital social ; SIRET/APE ne sont pas obligatoires sur la facture.

Logo absent des PDF : deux causes à ne pas confondre

Symptôme classique : le logo s'affiche dans le navigateur mais pas dans le PDF imprimé. Le diagnostic commence toujours en lecture seule :

P = env['ir.config_parameter'].sudo()
print(P.get_param('web.base.url'), P.get_param('report.url'))   # URLs de rendu
for c in env['res.company'].search([]):
    import base64
    raw = base64.b64decode(c.logo) if c.logo else b''
    print(c.id, c.name, len(raw), raw[:8])   # format réel du logo
  1. web.base.url erroné (ex. resté à http://odoo) → wkhtmltopdf n'atteint pas les assets. Remettre l'URL publique réelle. Si le serveur ne s'atteint pas par son domaine, définir report.url = http://localhost:8069 (fetch local).
  2. Le logo est un SVG (cause fréquente après réinstallation : Odoo regénère un logo SVG par défaut d'environ 470 octets). wkhtmltopdf (qt patched) ne rend pas le SVG → logo absent du PDF alors que le navigateur l'affiche. Solution : remettre un logo raster (PNG/JPG).
import base64
with open('/tmp/logo.png','rb') as f:
    env['res.company'].browse(CID).write({'logo': base64.b64encode(f.read())})
env.cr.commit()

Attention : un fichier déposé dans /tmp par l'utilisateur système doit être lisible par odoo (chmod 644).

Vérifier le rendu réel sans passer par l'interface :

rep = env['ir.actions.report'].search([('model','=','sale.order'),('report_type','=','qweb-pdf')], limit=1)
pdf, _ = env['ir.actions.report']._render_qweb_pdf(rep.report_name, res_ids=[ORDER_ID])
open('/tmp/devis.pdf','wb').write(pdf)   # rapatrier + ouvrir pour contrôle visuel

Note v19 : sale.report_saleorder est la vue QWeb, pas l'action ; chercher l'action via ir.actions.report (report_name réel = sale.report_saleorder_raw).

Fusionner des tâches en doublon — sans rien supprimer

Situation typique : deux tâches du même nom, l'une avec l'historique du chatter, l'autre avec la description et le devis. Objectif : tout réunir sur une seule et archiver l'autre — jamais la supprimer.

# 1. Transférer les messages chatter d'une tâche vers l'autre
messages = env['mail.message'].search([
    ('res_id', '=', SOURCE_ID), ('model', '=', 'project.task')])
for m in messages:
    m.write({'res_id': CIBLE_ID})
env.cr.commit()

# 2. Corriger la tâche cible (étape + partenaire)
cible = env['project.task'].browse(CIBLE_ID)
stage = env['project.task.type'].search([
    ('name', 'ilike', 'CDC'), ('project_ids', 'in', [cible.project_id.id])], limit=1)
cible.write({'stage_id': stage.id, 'partner_id': PARTNER_ID})
env.cr.commit()

# 3. Archiver le doublon (conservé, masqué du kanban)
env['project.task'].browse(SOURCE_ID).write({'active': False})
env.cr.commit()
  • Archivage = active: False — jamais unlink(). Relire un archivé : with_context(active_test=False).
  • Le changement d'étape génère une notification chatter (+1 message) — c'est normal.
  • Toujours vérifier après chaque étape : compte de messages, étape, partenaire.

Archivage et sécurité : les règles absolues

Confier son ERP à une IA exige un cadre de sécurité non négociable. Ces règles sont inscrites dans la méthode et appliquées à chaque opération.

  • Lecture seule sur les bases source — aucune modification.
  • Écriture Odoo uniquement via l'ORM (odoo shell) — jamais de SQL direct.
  • Dump de la base de données avant toute suppression de VPS ou d'instance.
  • Suppression d'un VPS : décision et action de l'utilisateur uniquement — jamais de l'IA.
  • Aucune credential dans le chat — SSH et odoo shell s'exécutent localement sur le VPS.
# Sur le VPS source
sudo -u postgres pg_dump VOTRE_BASE > /tmp/dump_$(date +%Y%m%d).sql
gzip /tmp/dump_$(date +%Y%m%d).sql

# Rapatrier sur le PC
scp ubuntu@vps-source.exemple.fr:/tmp/dump_*.sql.gz [CHEMIN_PROJET]/DOCS/archives/

Enfin, en toile de fond : les actions engageantes — envoi d'un devis, confirmation d'une commande, opération en production — restent des décisions humaines. L'IA prépare, vérifie et rend compte ; l'utilisateur valide.


Cette page est le pendant « pilotage » de la méthode. Pour le développement de modules custom Odoo 19 (modes de travail, protocole d'échange, pièges connus), voir la page Développer des modules Odoo avec Claude.

Méthode IA&TIC — © 2026 IA&TIC — Freddy Maillard & Fabrice-Loïc Barbier — CC BY-SA 4.0