Sous-traitance pour intégrateurs Odoo
Vos projets Odoo ne bloquent pas sur la compta.
Ils bloquent sur la boutique, sur la caisse, sur l'étiquette de colis qui ne sort pas de l'imprimante du dépôt. C'est la partie que personne n'aime chiffrer. C'est celle que je reprends.
Le constat
Une migration se vend sur l'ERP. Elle se juge sur ce que le client touche tous les jours.
Le plan comptable est carré, les stocks sont repris, la facturation tourne. Et pourtant le client râle.
Parce que sa vendeuse scanne un article sur deux. Parce qu'une page de rayon met trois secondes à s'afficher. Parce que l'étiquette de transport ne sort pas, et que personne ne sait dire pourquoi.
Ces sujets tombent entre deux chaises. Ils demandent de connaître Odoo en profondeur et d'aimer le web, CUPS, les DNS, les feuilles de style et les files d'impression. Dans beaucoup d'agences, il n'y a personne pour les prendre.
Je fais ça, et seulement ça. En sous-traitance, sous votre marque, sans jamais toucher à votre périmètre ERP.
Ce que je reprends
La boutique et le site, dans Odoo
Thème, tunnel de commande, click and collect, pages de rayon, fiches produit. SEO technique, balisage schema.org, flux produits, redirections. Dans Odoo, pas dans un WordPress posé à côté qu'il faudra synchroniser toute sa vie.
Ce qui se passe dans le dépôt
Page de préparation des commandes, étiquettes de transport qui sortent toutes seules, impression depuis le serveur vers la box IoT, files CUPS et pilotes ZPL, caisse et codes-barres. Le genre de détail qui décide si l'outil est adopté ou contourné.
L'acquisition, et des chiffres relisables
Newsletters et campagnes dans Odoo, API Conversions de Meta écrite à la main, GA4, PostHog, Search Console, IndexNow. Des mesures qu'on peut recouper et contredire, pas un tableau de bord de démonstration.
Mettre en production sans parier
Scripts de déploiement, sauvegarde avant chaque écriture, surveillance externe, chien de garde sur la prod, procédure de redémarrage qui se vérifie elle-même et sait revenir en arrière. Une boutique et une caisse sur la même instance ne pardonnent pas.
La douleur numéro un des migrations
Une migration Odoo perd les URL indexées. Celle-ci n'en a pas perdu une.
Odoo sert ses fiches produit sous /shop/... par une constante écrite en dur,
SHOP_PATH, appelée à 27 endroits du code. L'ancien site les servait à la racine
du domaine. Réécrire les adresses, c'était accepter de repartir de zéro sur Google, avec un
client qui voit son trafic s'effondrer le lendemain de la bascule et une agence sans réponse
à lui donner.
La parade n'est pas un plan de redirections : c'est un convertisseur d'URL werkzeug qui ne reconnaît que les slugs de produits publiés. Werkzeug essaie ses règles dans l'ordre, donc tout ce qui n'est pas un produit connu retombe sur le routage normal d'Odoo. Les pages de contenu, le back-office et les routes natives gardent la priorité.
Les 808 adresses indexées ont été conservées à l'identique. Il est resté 79 redirections de rubriques, toutes à zéro clic, au lieu des 887 prévues au départ.
Le périmètre a été tranché sur des données, pas au ressenti : six mois de Search Console exportés, 47 199 lignes relues. Verdict, 609 fiches produit cumulaient 281 clics quand la page d'accueil en faisait 3 857.
L'actif à protéger n'était donc pas le référencement des fiches, c'étaient les signaux locaux de la page d'accueil. Savoir ce qu'on ne doit pas protéger fait gagner autant de temps que le reste.
class EvProductSlugConverter(BaseConverter):
"""Convertisseur d'URL qui ne matche que les slugs de produits publiés."""
regex = MOTIF_SLUG
def to_python(self, value):
# lang=None est indispensable : le convertisseur tourne PENDANT le routage,
# avant qu'Odoo n'ait normalisé la langue du navigateur. Un visiteur qui
# envoie « Accept-Language: fr-FR » laisse le contexte sur fr_FR, langue non
# installée ici, et le moindre accès à l'ORM lève alors « Invalid language
# code » : HTTP 422 sur les fiches produit ET sur toutes les pages de contenu.
env = request.env(context={'lang': None})
produit = env['product.template'].sudo().search(
[('ev_url_slug', '=', value), ('is_published', '=', True)], limit=1
)
if not produit:
raise ValidationError() # werkzeug passe à la règle suivante
return produit.with_env(request.env)
Les cinq lignes de commentaire valent le code. Ce lang=None vient d'un incident
réel : un convertisseur s'exécute pendant le routage, avant qu'Odoo n'ait normalisé la langue
du navigateur. Un visiteur avec Accept-Language: fr-FR, langue non installée sur
cette instance, faisait tomber les fiches produit et toutes les pages de contenu
en HTTP 422. Ce genre de détail ne s'apprend pas dans la documentation.
Le site, en vrai
Odoo peut faire une boutique qui ne ressemble pas à Odoo.
Ce n'est pas un thème acheté. Thème sur mesure, tunnel en deux étapes, click and collect avec calendrier de retrait, tri par disponibilité réelle, pastille de rupture, données structurées complètes. Tout vit dans Odoo : un seul stock, un seul catalogue, une seule base clients.
Le tunnel de commande
L'adresse se vérifie chez bpost pendant qu'elle se tape.
Une adresse mal écrite se paie deux fois : l'étiquette part, le colis revient, et la cliente écrit au magasin. Le tunnel interroge donc bpost à la frappe et pose ses suggestions dans les champs d'Odoo, sans en ajouter un seul à l'écran.
Essayez Tapez « chaus » dans Rue, choisissez, puis tapez 8 dans Numéro. Les flèches parcourent la liste, Entrée choisit, Échap ferme. Le code postal et la ville interrogent le même niveau et se remplissent ensemble.
- Le widget officiel de bpost a été posé, puis retiré le même jour. Il ajoutait ses quatre champs au-dessus de ceux d'Odoo, avec ses libellés : la cliente voyait « code postal », « nom de rue », « numéro de maison », puis quinze centimètres plus bas « Code postal », « Rue », « Numéro ». Les mêmes informations, demandées deux fois.
- Il pesait 528 Ko d'Angular sur la page où la latence compte le plus de tout le site, et il lisait la clé d'API dans le HTML, donc elle était publique. On appelle maintenant le même service depuis le serveur : 16 Ko de JavaScript au total, et la clé ne quitte jamais la machine.
- Le contrat a été trouvé en lisant le widget, rien n'étant documenté : quatre niveaux qui s'enchaînent, localité, rue, numéro, boîte, chacun exigeant le contexte du précédent. Le paramètre s'appelle
locality, la réponse rendlocalityName, et l'en-tête estx-api-key: tout le reste répond 403 ou 400. - 220 ms de repos entre deux frappes, deux caractères minimum, six suggestions, quatre secondes de patience. En dessous de deux caractères tout le pays répond ; au-delà de quatre secondes, une suggestion arrive après que la cliente a fini de taper, donc elle ne sert plus à rien.
- Le tunnel ne dépend jamais du service. Aucun champ n'est verrouillé, aucune valeur refusée, et si bpost ne répond pas il ne se passe simplement rien. Une adresse étrangère, un lieu-dit, une rue trop neuve : tout se tape à la main. Une API qui tombe ne doit pas empêcher une vente.
- Et un interrupteur. Vider la clé dans les paramètres éteint les suggestions : le marqueur disparaît de la page, le script ne trouve rien et s'arrête. Le formulaire retrouve sa forme d'origine sans redémarrage ni mise à jour de module.
Le service de validation ne répond jamais « faux ». Il rend ce qu'il a su reconnaître, et corrige en silence : « Peron » devient « PERRON ». Le seul signal exploitable est le champ qui revient vide quand il n'a pas su placer la rue ou le numéro.
« 82 Chaussée de Mons » ne rend ni rue ni numéro. La même adresse écrite « Chaussée de Mons 82 » rend les deux. Une bonne part des adresses signalées ne sont donc pas fausses, seulement mal écrites, et se réparent sans rien demander à la cliente.
Le même service passe en revue les adresses déjà en base, celles reprises de l'ancien site comme celles saisies en magasin. On répare avant que le colis parte, pas après qu'il est revenu.
Le terrain, en vrai
Une page de préparation, écrite pour un téléphone tenu d'une main.
L'ancien site avait un outil de préparation maison, 5 292 lignes accumulées au fil des ans, qui mourait avec lui. Reconstruit en module Odoo : une route, une page autonome sans le gabarit de la boutique, aucun framework et aucune police distante. Elle s'ouvre vite sur le wifi du magasin, y compris quand il est mauvais.
Essayez Cochez une ligne : elle se barre. Cochez tout un bon et le bouton de validation apparaît, puis demande confirmation en nommant la cliente. La flèche en haut à droite remet les deux bons dans la file.
- 44 px minimum par cible tactile. En dessous, on rate la case et on coche la ligne d'à côté. Ici, ça veut dire préparer le mauvais article.
- La vignette fait 96 × 128, pas 72 × 96. C'est la photo qui fait reconnaître la pièce dans un rayon, le libellé ne sert qu'à confirmer. Trop petite, elle oblige à lire, et lire est lent.
- Le bouton photo est hors du label. Dedans, un clic sur l'image ouvrirait la galerie et cocherait la case du même geste.
- La dernière case ne valide pas. Cocher dit « j'ai la pièce en main » : fréquent, réversible. Valider expédie, crée une étiquette réelle et facture un envoi. Le bouton n'apparaît que quand tout est coché, et la confirmation nomme la cliente.
- Rechargement toutes les 30 secondes, par injection des seules cartes nouvelles. Jamais par rechargement de la page : la préparatrice a le pouce sur l'écran.
- Fenêtre de sept jours, « tout » par défaut. La base portait cinq bons prêts dont le plus vieux datait de cinq mois. Un bon oublié doit sauter aux yeux, pas se cacher derrière le bon onglet.
auth="user" ne protège rien ici : toute cliente ayant un compte est un res.users. Ce qui tient, c'est le refus des comptes partagés plus l'exigence du groupe Stock.
stock.move.line.picked et stock.move.picked sont deux champs distincts, le premier n'alimente pas le second, et button_validate ne regarde que le second.
Deux commandes préparées, zéro étiquette sortie. La recherche d'imprimante ramenait celle des étiquettes d'article, et le transporteur du bon valait in_store. Corrigé, et écrit.
Un exemple de tuyauterie
Le flux produits, enrichi article par article.
1 557 articles servis en continu par un contrôleur Odoo. Odoo ne sait pas produire ça seul : la catégorie Google, le genre, la tranche d'âge et les images secondaires ont été déduits du catalogue et ajoutés. Voici un article, tel qu'il sort aujourd'hui.
<item>
<g:id>01-729053-604-blanc-L</g:id>
<g:item_group_id>T19092</g:item_group_id>
<title>Débardeur avec finition en maille filet - YAYA - blanc (L)</title>
<link>https://[domaine du client]/debardeur-avec-finition-en-maille-filet-yaya-blanc</link>
<g:image_link>.../product.product/37438/image_1920</g:image_link>
<g:additional_image_link>.../product.image/1116/image_1920</g:additional_image_link>
<g:additional_image_link>.../product.image/1117/image_1920</g:additional_image_link>
... cinq autres images secondaires
<g:availability>in_stock</g:availability>
<g:price>34.95 EUR</g:price>
<g:brand>YAYA</g:brand>
<g:size>L</g:size>
<g:google_product_category>212</g:google_product_category>
<g:gender>female</g:gender>
<g:age_group>adult</g:age_group>
</item> Les preuves
Huit choses trouvées sur un seul dossier.
Une boutique de prêt-à-porter, site public et caisse sur la même instance, migrée de la V14 à la V19. Tout ce qui suit a été mesuré avant et après, pas estimé. Aucun de ces points n'était dans le cahier des charges, et c'est exactement le problème.
13 469 références sur 23 691. La règle Lot barcodes interceptait les codes internes avant la règle Produit. Le défaut était antérieur à la migration et personne ne l'avait jamais nommé : comme une partie des articles passait, tout le monde croyait à un scanner capricieux.
Les visiteurs anonymes du site naviguaient avec les droits d'un administrateur système. Le symptôme remonté était du SEO : un sitemap de 5 759 URL pour 756 produits publiés. La cause était de la sécurité. Odoo ne filtre les produits dépubliés que pour les utilisateurs externes.
27 000 règles de prix mortes réévaluées à chaque affichage, et un champ calculé qui finissait par s'appeler lui-même. Deux causes indépendantes qui se masquaient l'une l'autre dans le profil.
wkhtmltopdf redemande ses feuilles de style à Odoo, en HTTP, sur le serveur vivant. Avec deux workers, un rendu un peu lourd les prend tous les deux et plus rien ne répond. Diagnostic, puis chien de garde qui tue les rendus qui s'éternisent.
L'e-mail « donnez-nous votre avis » de l'ancien site n'était ni dans l'outil d'envoi ni dans une extension du marché : un greffon maison, chargé sans activation, invisible dans l'administration. 281 demandes entre mai et août, 26 avis récoltés, note passée de 4,6 à 4,7. Avant de déclarer un automatisme perdu, il faut aller voir sur l'ancien serveur, il répond encore.
Un PNG envoyé dans une file ZPL. CUPS répondait fièrement completed, le pilote jetait le travail à la poubelle. Deux jours de colis étiquetés à la main avant que quelqu'un ose dire que ça ne marchait pas.
Le programme de fidélité repris de WooCommerce fonctionnait parfaitement. Aucune page du tunnel n'en soufflait mot. Le mécanisme existait déjà dans Odoo : il suffisait de l'afficher là où la cliente est identifiée et où une remise l'intéresse encore.
Après avoir compris pourquoi Odoo mettait une heure là où l'ancien outil mettait deux minutes : un seul fil de cron, et deux tâches qui ne peuvent pas se croiser. Et après avoir mesuré que l'accélérateur maison rendait l'envoi plus lent, puis l'avoir retiré.
Ce ne sont pas des anecdotes. Chacun de ces points a été écrit, daté et rangé dans une base de connaissances relisable, avec la cause, la parade et le piège à ne pas refaire. C'est ce qui reste chez vous quand je pars.
Quatre pièges que la documentation ne mentionne pas
Chacun a coûté une demi-journée. Une fois.
Ceux-là sont dans le code de l'éditeur, pas dans le mien. Ils ont un point commun : ils ne lèvent aucune erreur, ou ils la lèvent très loin de leur cause. C'est pour ça qu'ils coûtent cher la première fois, et rien du tout la deuxième.
literal_eval Les domaines des campagnes marketing sont lus avec literal_eval, pas safe_eval : ni datetime, ni context_today, ni relativedelta n'y passent. Tout calendrier doit vivre dans un champ stocké qu'un cron entretient.
marketing.campaign Une campagne aspire tout l'historique le jour où on la démarre. Sur les commandes, ça faisait 11 849 lignes reprises de l'ancien site, prêtes à recevoir un e-mail. Ce qui protège : un filtre sur l'origine web plus un plancher de date. Ne jamais élargir un domaine de campagne sans compter d'abord ce qui entrerait.
ir.qweb Odoo surveille les balises de script de ses pages de contenu et réécrit ce qu'il ne reconnaît pas. Un pixel publicitaire collé dans l'éditeur ne part jamais, et rien ne le signale. Le suivi des conversions a donc été écrit côté serveur, avec l'API dédiée.
cookie Un cookie de consentement illisible fait tomber tout le site en 500, pas seulement la barre qui le lit. Un visiteur avec un cookie corrompu ne voit plus rien, et son navigateur le lui renvoie à chaque tentative.
L'autre moitié de l'honnêteté
Trois erreurs qui sont les miennes, et ce qu'elles ont produit.
Vous ne cherchez pas quelqu'un qui ne tombe jamais, ça n'existe pas. Vous cherchez quelqu'un qui sait ce qui s'est passé, qui le dit avant qu'on le découvre, et qui en sort un garde-fou au lieu d'une promesse.
@api.depends('id') est interdit en Odoo 19 et l'exception tombe
à l'import du module : le registre entier refuse de charger, tout
répond 500, caisse comprise. C'était pendant la fermeture de caisse, et la gérante a vu
« Connexion perdue » à l'écran. Sa fermeture a abouti quand même, 36 commandes, écart de
0,00 €.
Trois erreurs de méthode enchaînées, et aucune n'est celle d'Odoo : un modèle déployé sans mettre à jour le module, un arrêt de service qui rend la main avant l'arrêt réel donc deux mises à jour mortes sans que rien ne le dise, et un journal vide pris pour une réussite parce que le contrôle lisait un fichier que la configuration ne remplissait pas.
Ce qui en est resté Une liste de redémarrage exécutable où chaque case se vérifie elle-même, et qui refuse de démarrer tant qu'une session de caisse est ouverte. Plus une règle : une réparation ponctuelle laissée en place devient un piège. Celle posée après cet incident a écrasé un déploiement le lendemain. Retirée.
Odoo laisse la ligature œ intacte dans ses slugs : aucune normalisation
Unicode ne la décompose, et \w la considère comme une lettre. Mon
convertisseur, lui, n'acceptait que l'ASCII. La route ne matche jamais, la fiche répond
404, et rien ne le signale : ni erreur, ni alerte, ni ligne de journal.
Deux pièces en vente étaient dans ce cas, dont une depuis trois mois. Les mots en cause : « cœur » et « nœud », du vocabulaire courant en prêt-à-porter.
Ce qui en est resté Un motif de slug écrit une fois, relu par les convertisseurs et par le code qui écrit. Il refuse désormais d'enregistrer en silence une adresse qu'il ne saurait pas relire : l'erreur se voit à l'écriture, pas trois mois plus tard.
website_meta_title est un Char(size=70) et Odoo tronque en
silence. Mon script de reprise a suivi la même limite, au caractère et non au mot.
Soixante-trois fiches se sont retrouvées dans Google avec un titre qui s'arrête au milieu
du nom de l'enseigne. Taux de clic mesuré sur ces pages : 0,4 %.
Ce qui en est resté La coupe au mot, et un inventaire des 4 226 adresses avec les titres et les métas réellement servis, pas ceux qu'on croit avoir posés. Un chiffre qu'on ne voit pas est un chiffre auquel on finit par croire.
Quand ça tombe
Deux pannes, un garde-fou, et ce qu'il en reste.
La boutique et la caisse ne répondaient plus. Le serveur, lui, allait très bien : charge à 0,01, 4,8 Go de mémoire libres, SSH instantané. Quand la machine est au repos et que le site est mort, c'est un interblocage, pas une surcharge.
Une commande payée pose un verrou, puis lance le rendu du bon de livraison.
wkhtmltopdf redemande ses feuilles de style à Odoo, en HTTP, sur le serveur
vivant. Avec deux workers, le premier attend le PDF, le second attend le verrou : plus
personne pour servir la requête qui débloquerait tout. Et limit_time_real
réglé à dix heures, donc rien ne serait venu dénouer ça avant le soir.
Le déblocage tient en un kill du rendu suspendu. Surtout pas un
redémarrage : la caisse du magasin tourne sur cette instance, et un redémarrage coupe un
encaissement en cours.
Chaque minute : un rendu PDF de plus de 120 secondes est tué, les verrous PostgreSQL tenus depuis plus de 60 secondes sont signalés, et la page de connexion est appelée en local avec une seconde tentative avant de crier. Un rendu légitime prend six secondes.
Il pousse ensuite son état vers un moniteur externe. S'il se tait, l'alerte tombe : un surveillant mort ne prévient de rien, et son silence ressemble beaucoup au calme.
Vérifié sur la production avec une vraie fausse panne : processus lancé à 13h33m59, tué tout seul à 13h36m04, après 121 secondes. Ce test a révélé un défaut invisible autrement, la fenêtre de silence était trop courte et aurait produit de fausses alertes. Son périmètre s'arrête là : il ne redémarre jamais rien.
La box qui pilote l'imprimante ticket ne redémarrait plus. SSH répondait, mais les ports web étaient tous fermés, en connection refused et non en timeout : la machine est vivante et refuse activement, donc rien n'écoute.
Carte micro-SD sortie, lue dans un lecteur dont on a d'abord prouvé qu'il fonctionne.
Elle annonce 127 139 840 octets, une taille qui ne correspond à aucune
carte existante, sans aucune table de partition, et son premier secteur contient
55aa répété, un motif de test électronique. Une table valide porte cette
signature sur ses deux derniers octets. Le contrôleur a perdu son
adressage.
Le redémarrage n'a rien cassé, il a révélé une panne installée depuis des mois : un Linux qui tourne garde l'essentiel en mémoire et ne relit presque jamais son disque. Remède, image officielle vérifiée par empreinte, carte haute endurance, et un clone de secours dans le tiroir. Une caisse tombe aussi pour des raisons qui n'ont rien de logiciel.
Ce qui reste dans le dépôt
Vingt-quatre modules, nommés par leur rôle.
Le préfixe porte le nom du client, il est masqué ici. Chacun est un module Odoo autonome, installé en production, versionné, avec ses commentaires en français et sa raison d'être écrite en tête de fichier.
Comment on travaille
Quatre règles, écrites avant de commencer.
Votre client ne me voit pas, ou il me voit sous votre nom. Au choix, et c'est le vôtre. Je ne démarche jamais un compte rencontré chez vous.
Un périmètre écrit, un prix, une date. Ce qui sort du périmètre est chiffré à part, avant d'être fait. Pas de régie qui dérive et qu'on découvre à la facture.
Du code commenté en français, des modules propres hors de vos arbres git, les scripts de déploiement et la procédure de retour arrière. Rien qui ne tourne que sur ma machine.
Je reste sur le web, le terrain et l'exploitation. La compta, les stocks, la production et la paie restent chez vous. Si un correctif doit passer par votre périmètre, je le documente et je vous le passe.
Les questions qui viennent toujours
Six réponses, avant le premier appel.
Une migration vers Odoo fait-elle perdre le référencement ?
Pas si les adresses sont conservées. Odoo sert ses fiches produit sous /shop/... par une constante écrite en dur, appelée à 27 endroits du code. Un convertisseur d'URL werkzeug qui ne reconnaît que les slugs de produits publiés permet de garder les adresses de l'ancien site à l'identique, sans voler les URL des pages de contenu ni des routes natives. Sur le dossier de référence, les 808 adresses indexées ont été conservées et il n'est resté que 79 redirections de rubriques, toutes à zéro clic, au lieu des 887 prévues au départ.
Travaillez-vous en marque blanche pour une agence Odoo ?
Oui, c'est le mode par défaut. Le client final ne me voit pas, ou il me voit sous le nom de l'agence : c'est l'agence qui choisit. Je ne démarche jamais un compte rencontré en sous-traitance.
Quel périmètre Odoo prenez-vous, et lequel ne prenez-vous pas ?
Je prends le site et la boutique dans Odoo, le tunnel de commande, le SEO technique et les flux produits, les outils de terrain (préparation de commandes, étiquettes de transport, impression vers box IoT, caisse et codes-barres) et l'exploitation (déploiement, surveillance, retour arrière). Je ne touche pas à la comptabilité, aux stocks, à la production ni à la paie : ce périmètre reste chez l'intégrateur.
Comment se facture une mission de sous-traitance Odoo ?
Par lot, au forfait : un périmètre écrit, un prix et une date. Ce qui sort du périmètre est chiffré à part avant d'être réalisé. Pas de régie qui dérive et qu'on découvre à la facture.
Que reste-t-il à l'agence à la fin de la mission ?
Des modules Odoo propres déposés dans un répertoire distinct de vos arbres git, du code commenté en français, les scripts de déploiement et la procédure de retour arrière, plus une base de connaissances qui documente chaque cause, chaque parade et chaque piège rencontré.
Sur quelle version d'Odoo intervenez-vous ?
Odoo 19, en production, boutique publique et point de vente sur la même instance. L'expérience de référence est une migration complète de V14 à V19, réalisée en six semaines.
Un dossier qui traîne depuis trois mois ?
Décrivez-le en cinq lignes. Je réponds sous 48 h : si c'est pour moi, ce que ça coûte et combien de temps ça prend. Si ce n'est pas pour moi, je le dis aussi, et je le dis vite.