Il existe une version de cet article qui affirme qu'OWL est simple, réactif et un véritable plaisir à utiliser. Cette version est incomplète. OWL est indéniablement performant, mais uniquement si vous comprenez les limites de ses modèles et la raison pour laquelle sa documentation vous conduira souvent à consulter directement le code source d'Odoo.
Ce texte est le fruit de six mois de développement OWL en production, répartis sur plus de 40 fichiers JavaScript, six modules personnalisés et une plateforme de point de vente (POS) Odoo 18 desservant simultanément plusieurs entreprises. Nous avons géré les flux de paiement, la tarification en temps réel via des API externes, la conformité en matière de sécurité alimentaire au niveau des lots, les restrictions liées aux programmes de fidélité, ainsi que la synchronisation par lots avec un mécanisme de récupération d'erreurs sensible à l'état de la connexion. Tout ce qui suit découle directement de ce travail.
Pourquoi il vaut la peine de bien connaître OWL
OWL (Odoo Web Library) constitue désormais le socle de l'ensemble d'Odoo 16 et des versions ultérieures. Il s'agit d'un framework réactif basé sur les composants — imaginez Vue 3, mais doté d'un système de templates XML au lieu des SFC, et d'un modèle d'extension fondé sur les patchs plutôt que sur les mixins.
Le frontend du point de vente (POS) dans Odoo 17 et 18 repose entièrement sur OWL. Si vous entreprenez la moindre personnalisation non triviale du POS Odoo (flux de paiement, écrans sur mesure, intégrations externes), vous écrivez du code OWL. C'est inéluctable, et tenter de greffer du JavaScript « vanilla » en parallèle ne fera que vous desservir.
1. Patch ou héritage : abandonnez l'habitude d'étendre les classes.
C'est la décision architecturale la plus importante dans le développement OWL sous Odoo 18, et la documentation ne la souligne pas suffisamment.
Le réflexe naturel, pour quiconque est issu du monde de la programmation orientée objet, consiste à étendre une classe. Le problème est le suivant : les écrans du point de vente (POS) d'Odoo sont enregistrés par référence de classe. Remplacer cette référence est une opération fragile ; l'ordre de chargement des modules devient critique et, lorsque deux modules tentent simultanément d'étendre le même écran, on se heurte à des problèmes d'héritage en diamant dont le débogage est véritablement pénible.
Le bon modèle est patch():
import { patch } from "@web/core/utils/patch";
import { PaymentScreen } from "@point_of_sale/app/screens/payment_screen/payment_screen";
patch(PaymentScreen.prototype, {
async validateOrder(isForceValidate) {
this._filterPaymentMethodsByContext();
return await super.validateOrder(isForceValidate);
},
_filterPaymentMethodsByContext() {
const order = this.currentOrder;
const hasNuFund = order.lines.some(l => l.product.is_nu_fund);
if (hasNuFund) {
this.payment_methods_from_config = this.payment_methods_from_config
.filter(pm => !pm.is_wallet);
}
}
});
Dans notre base de code, PaymentScreen a été patché par trois modules différents. Tous trois se sont composés proprement. super Cela fonctionne exactement comme on s'y attendrait au sein d'un patch. La référence à la classe d'origine reste intacte ; chaque module ajoute son comportement de manière non destructive.
Compromis honnête : les correctifs sont plus difficiles à tracer dans un débogueur. Les traces de pile n'indiquent pas l'ordre d'application des correctifs. Lorsque quelque chose se rompt à l'intersection de deux correctifs, vous devez @web/core/utils/patch Mobiliser la conscience de la source pour déterminer qui a appelé quoi.
2. useState : Ce n'est pas React, ne le traitez pas comme React.
OWL's useState Crée un Proxy réactif sur un objet simple. Le modèle de mutation is Vue 3's reactive(), non React's setState. Cette distinction engendre de véritables bugs pour les développeurs venant de React.
import { useState, useService } from "@odoo/owl";
export class CustomerSearch extends Component {
setup() {
this.state = useState({
searchTerm: "",
results: [],
showResults: false,
isLoading: false,
selectedIndex: 0,
});
this.orm = useService("orm");
this._searchDebounced = debounce(this._search.bind(this), 300);
}
onInput(ev) {
this.state.searchTerm = ev.target.value;
this._searchDebounced();
}
}
Une mutation directe déclenche un nouveau rendu.: this.state.searchTerm = ev.target.value Ça marche, tout simplement. Pas de setter, pas de dispatch.
Le piège qui guette tout développeur React : les tableaux imbriqués useState ne sont PAS automatiquement réactifs au niveau imbriqué. Cela ne déclenchera pas de nouveau rendu :
this.state.results.push(newPartner); //FAUX – Le composant ne se re-rendra pas.
Le Proxy surveille la référence de niveau supérieur, et non les mutations au sein du tableau. Remplacez systématiquement :
this.state.results = await this.orm.searchRead(...); // Replace entirely
Mets-le sur un Post-it.
3. Appels ORM avec délai d'attente : searchRead plutôt que search + read
Appels de recherche client orm.searchRead on every keystroke with a 300ms debounce. Use searchRead rather than search followed by read: one RPC call instead of two, which matters during busy service hours.
async _search() {
if (!this.state.searchTerm || this.state.searchTerm.length < 2) {
this.state.results = [];
return;
}
this.state.isLoading = true;
try {
const domain = [
"|", "|", "|",
["name", "ilike", this.state.searchTerm],
["phone", "ilike", this.state.searchTerm],
["email", "ilike", this.state.searchTerm],
["barcode", "=", this.state.searchTerm],
];
if (this.props.enterpriseIds?.length) {
domain.unshift(["parent_id", "in", this.props.enterpriseIds]);
domain.unshift("&");
}
this.state.results = await this.orm.searchRead(
"res.partner",
domain,
["id", "name", "parent_name", "phone", "email"],
{ limit: 10 }
);
this.state.showResults = true;
} finally {
this.state.isLoading = false;
}
}
Le filtre d'entreprise présenté ci-dessus illustre le fonctionnement de l'isolation multi-tenant : les clients de l'Entreprise A n'apparaissent jamais dans la session de point de vente de l'Entreprise B.
4. useTrackedAsync pour les appels d'API externes non bloquants
Les appels d'API externes (moteurs de tarification, passerelles de paiement) génèrent un problème familier de gestion d'état asynchrone : indicateur de chargement, stockage des résultats, gestion des erreurs et nettoyage lors du démontage. Rédiger ce code répétitif pour chaque appel est fastidieux. OWL ships useTrackedAsync. Use it.
import { useTrackedAsync } from "@web/core/utils/hooks";
setup() {
this.fetchNuQuotation = useTrackedAsync(this._fetchNuQuotation.bind(this));
}
async _fetchNuQuotation(cartData) {
const response = await fetch("/cashier/Operation/Quotations", {
method: "POST",
headers: { "Authorization": `Bearer ${this.nuToken}` },
body: JSON.stringify(cartData),
});
if (!response.ok) throw new Error(`NU API error: ${response.status}`);
return await response.json();
}
Ce que vous récupérez est un objet doté de propriétés réactives.: isLoading, result, and error. Votre modèle y réagit automatiquement, et les appels en cours sont automatiquement annulés lorsque le composant est détruit. Si vous effectuez une gestion manuelle isLoading Remplacez les booléens utilisés pour les appels asynchrones.
5. Application de correctifs PosStore pour les comportements transversaux
Certains comportements doivent être accessibles depuis n'importe quel composant ou écran, sans injection de service supplémentaire. PosStore est l'endroit idéal.
Certains comportements doivent être accessibles depuis n'importe quel composant ou écran, sans injection de service supplémentaire. PosStore est l'endroit idéal.
import { patch } from "@web/core/utils/patch";
import { PosStore } from "@point_of_sale/app/store/pos_store";
patch(PosStore.prototype, {
sortLotsByFefo(lots) {
return [...lots].sort((a, b) => {
if (!a.removal_date && !b.removal_date) return a.lot_name.localeCompare(b.lot_name);
if (!a.removal_date) return 1;
if (!b.removal_date) return -1;
return new Date(a.removal_date) - new Date(b.removal_date);
});
},
getLotForProduct(product, existingLots) {
const availableLots = this.sortLotsByFefo(
existingLots.filter(l => l.product_id === product.id && l.qty > 0)
);
return availableLots[0] || null;
}
});
Chaque composant qui utilise déjà this.pos Il accède immédiatement à ces méthodes, sans injection supplémentaire. Utilisez PosStore pour la logique métier qui s'étend sur toute la durée de la session POS ; utilisez les services OWL pour les infrastructures transversales, telles que les clients HTTP ou les systèmes de notification.
6. Logique métier respectueuse des fuseaux horaires avec Luxon
Ne jamais utiliser new Date() pour l'évaluation des règles métier dans Odoo. Il ne prend pas en charge les fuseaux horaires. Odoo intègre Luxon. Utilisez-le.
Nous avons mis en place des restrictions horaires pour les programmes de fidélité : une récompense n'est échangeable que durant des plages horaires prédéfinies. Une gestion erronée des fuseaux horaires implique qu'une restriction fixée de « midi à 14 h » se déclenche en réalité à 10 h UTC, selon l'horloge à laquelle on se fie.
import { DateTime } from "luxon";
patch(PosOrder.prototype, {
_applyReward(reward, coupon, args) {
if (reward.slot_restriction_ids?.length) {
const now = DateTime.now().setZone(this.pos.config.timezone || "local");
const currentDay = now.weekdayLong.toLowerCase().slice(0, 3);
const currentDecimalTime = now.hour + now.minute / 60;
const allowed = reward.slot_restriction_ids.some(slot =>
slot.day === currentDay &&
currentDecimalTime >= slot.hour_from &&
currentDecimalTime <= slot.hour_to
);
if (!allowed) {
return {
successful: false,
reason: `This reward is only available during: ${this._formatSlots(reward.slot_restriction_ids)}`
};
}
}
return super._applyReward(reward, coupon, args);
}
});
DateTime.now().setZone(timezone) Fournit l'instant présent dans le fuseau horaire configuré du terminal de point de vente, en tenant compte de l'heure d'été. Le code n'est pas complexe, mais sans Luxon, il cesse discrètement de fonctionner correctement deux fois par an, lors du changement d'heure.
7. Architecture de récupération d'erreurs pour l'asynchrone-synchrone
Il est facile de sous-estimer l'impact des interruptions réseau lors de la synchronisation des commandes. Une mauvaise gestion de ces incidents peut entraîner une perte de données silencieuse, des doublons de commandes ou l'impossibilité pour les caissiers d'effectuer des remboursements. La solution qui s'est avérée efficace pour nous repose sur un automate d'états minimal au sein du PosStore, doté de transitions d'état explicites.
patch(PosStore.prototype, {
setup() {
super.setup(...arguments);
this.syncState = useState({
status: "idle", // idle | syncing | error | disconnected
syncingOrders: new Set(),
lastError: null,
});
},
async syncAllOrders(orders) {
const BATCH_SIZE = 20;
this.syncState.status = "syncing";
for (let i = 0; i < orders.length; i += BATCH_SIZE) {
const batch = orders.slice(i, i + BATCH_SIZE);
try {
await this.orm.call("pos.order", "sync_from_ui", [batch]);
} catch (error) {
if (error instanceof ConnectionLostError) {
this.syncState.status = "disconnected";
return; // Retry when connectivity returns
}
if (error instanceof RPCError) {
this.syncState.status = "error";
this.syncState.lastError = error.message;
return; // Show error to user
}
throw error; // Unexpected: surface it
}
}
this.syncState.status = "idle";
}
});
La distinction cruciale: ConnectionLostError (network dropped, retry silently) vs RPCError (server rejected, tell the user). Fusionner les deux en un seul bloc `catch` générique entraîne systématiquement un comportement incorrect dans l'un des deux cas. Relancer les erreurs inattendues empêche les bugs de se perdre dans le vide.
Leçons tirées de la production
Ce qui a mieux fonctionné que prévu : le système de patchs. Trois modules patchant la même chose. PaymentScreen.prototype Composé sans problème. La super La chaîne a fonctionné correctement du début à la fin. C'est véritablement le modèle adéquat pour l'écosystème de modules d'Odoo..
Ce qui s'est avéré plus difficile que prévu : la boucle de développement. Toute modification d'un module impliquant l'application de correctifs nécessite un rechargement complet du système de point de vente (POS). Avec six modules et des interdépendances complexes, ce cycle devient lent. Prévoyez du temps en conséquence.
Ce que nous ferions différemment : Commencer par useTrackedAsync Dès le premier jour. Nous avons rédigé manuellement le code standard de gestion de l'état de chargement dans les deux premiers modules, avant de réaliser que le hook existait.
Concernant le manque de documentation : la documentation d'OWL décrit l'API, mais pas les patterns. Qu'est-ce qui relève du PosStore, d'un service ou d'un composant ? Les réponses à ces questions se trouvent dans le code source même d'Odoo — plus précisément dans la structure des modules officiels du point de vente (POS) —, mais elles ne figurent pas dans la documentation officielle. Lire. @point_of_sale/app/ avant d'écrire vos propres patrons.
Vaut-il la peine d'investir dans OWL ?
Pour le fonctionnement du module POS d'Odoo : oui, sans aucune réserve. Il n'y a pas d'autre voie possible. Si vous réalisez des personnalisations sérieuses du POS sur Odoo 17 ou 18, OWL est l'environnement incontournable. La question est de savoir si vous allez l'apprendre correctement ou si vous allez lutter contre lui.
Le parcours d'apprentissage qui a fonctionné pour nous : lire d'abord le dépôt GitHub d'OWL (et non la documentation d'Odoo), puis lire @point_of_sale/app/ Consultez le code source pour comprendre l'utilisation réelle, puis rédigez un petit correctif pour un écran de point de vente existant avant de développer vos propres composants. Cette approche axée sur le correctif vous permet de travailler directement sur le système réel, et ce, dès le départ.
Fanto développe des solutions de point de vente Odoo sur mesure pour les environnements complexes de la restauration et du commerce de détail. Si vous travaillez sur un projet Odoo nécessitant une personnalisation poussée du point de vente, contactez-nous. Nous vous indiquerons ce qui présente réellement des difficultés techniques avant même que vous ne commenciez.