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.
| Champ | Changement en Odoo 19 CE |
|---|---|
res.partner.mobile | Supprimé (fusionné dans phone) — AttributeError à l'accès |
sale.order.line.tax_id | Renommé tax_ids (many2many) |
res.users.groups_id | Renommé group_ids (+ all_group_ids) |
product.template.uom_po_id | Supprimé — ne reste que uom_id |
sale.order.line.product_uom | Renommé 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éparateur | En-tête de section (comme un frontmatter Obsidian) |
| Texte | Champ libre |
| Date / Date & Temps | Horodatage |
| Booléen | Case à cocher |
| Sélection | Liste 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 queActif [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 :
| Obsidian | Odoo |
|---|---|
| Dossier projet | Projet Odoo |
| Note d'index (nœud racine) | Tâche mère |
| Sous-note | Sous-tâche (peut être dans un autre projet) |
| Frontmatter | Propriétés custom de tâche |
| [[wikilink]] | Lien parent/sous-tâche cross-projet |
Exemple d'organisation de référence :
| Projet | Rôle | Étapes |
|---|---|---|
| Pilotage | Actions de gestion à traiter | Urgent · À traiter · En attente · Fait |
| Clients | Relation client permanente | Prise de contact · CDC/Devis · Actif · En pause · Archivé |
| Infrastructure | Infrastructure clients | À qualifier · En cours · En production · Archivé |
| Développement | Développements sur mesure | Arriéré · Spécifications · Dev · Tests · Délivré |
| Méthode/IA | Méthode, agents, MCP | Idée · En cours · Validé · Archivé |
| Formation | Formations | À préparer · En cours · Réalisé |
| Support | Maintenance, support | Nouveau · En cours · En attente · Résolu · Refusé |
| Suivi VPS | Inventaire 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 :
- L'alias projet (ex.
client@exemple.fr→ projet Clients) - 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é viaparent_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.champavant 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érifierorder.amount_tax == 0etamount_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.userslié au partenaire portebase.group_portal(champgroup_idsen 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})puisenv.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_revenuede 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 enwriteséparé : à la création il reste souventFalse(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_ids'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.orderbrouillon, rendre le PDF, puisunlink()— 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) etcompany_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_footerde 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
web.base.urlerroné (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éfinirreport.url=http://localhost:8069(fetch local).- 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— jamaisunlink(). 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