Quiconque vend des packs dans Odoo, par exemple un appareil de mesure avec accessoires et montage, un poste de travail avec matériel et installation, ou un pack de maintenance composé de plusieurs prestations, a besoin de deux choses à la fois dans le devis : la composition doit figurer dans la commande afin que l'entrepôt, la technique et la facturation sachent ce qui est livré. Mais sur l'impression destinée au client, chaque position avec son prix unitaire ne doit pas apparaître dans tous les cas.
Le module OCA sale_product_set permet d'insérer en un clic des ensembles de produits (nomenclatures de vente) dans un devis. Il ne décide toutefois pas de l'aspect à l'impression. C'est pourquoi nous avons développé le module Sale Product Set Layout (sale_product_set_layout). Il est écrit pour Odoo 19 et disponible gratuitement sur GitHub, en tant que logiciel libre sous licence AGPL-3 :
github.com/Alex0176/odoo-sale-product-set-layout
Contenu de cet article
- Le problème : des ensembles de produits sans influence sur l'impression
- Ce que fait le module
- Les trois modes d'impression en détail
- Lignes masquées et prix fixe
- Mise en œuvre technique
- Installation sans effets secondaires indésirables
- Utilisation pas à pas
- Tests
- Limites et mention juridique
- Questions fréquentes
- Assistance de Biricon IT Services
Le problème : des ensembles de produits sans influence sur l'impression
Dans Odoo, un ensemble de produits est une liste préparée de produits avec leurs quantités. Au lieu de rechercher dix articles un par un, on choisit dans le devis Ajouter un ensemble et on obtient toutes les lignes d'un coup. Cela fait gagner du temps et évite d'oublier des positions. Le module sale_product_set du dépôt OCA/sale-workflow fait exactement cela ; les ensembles eux-mêmes proviennent de product_set dans le dépôt OCA/product-attribute.
En pratique, trois lacunes subsistent :
- Aucun regroupement visible. Les lignes de l'ensemble atterrissent dans le devis comme des positions isolées. Après l'insertion, on ne voit plus quelles lignes vont ensemble, et le total de l'ensemble n'apparaît nulle part.
- Aucune influence sur l'impression. Tantôt le client doit voir chaque position avec son prix, tantôt seulement le prix du pack, tantôt une seule ligne. C'est une décision à prendre par ensemble ou par client, et non à refaire à la main pour chaque devis.
- Les corrections de prix sont peu élégantes. Si un pack doit avoir un prix fixe différent de la somme des prix catalogue, il faut une ligne de correction. Elle a sa place dans le calcul, mais pas forcément sur le papier du client.
Odoo 19 fournit les éléments pour résoudre cela proprement : des sections et sous-sections dans les lignes de devis, avec les options Masquer les prix et Masquer la composition (champs collapse_prices et collapse_composition). Le module relie l'ensemble de produits à ces options standard, au lieu d'inventer un format d'impression propre.
Ce que fait le module
- Insérer l'ensemble sous forme de section. L'assistant Ajouter un ensemble crée une ligne de section portant le nom de l'ensemble, par exemple 2 × Kit de mesure Standard, et place les lignes de l'ensemble en dessous. Les sections à l'intérieur de l'ensemble deviennent des sous-sections.
- Mode d'impression par ensemble. On définit une fois dans l'ensemble comment il est imprimé. Lors de l'insertion, le mode peut être modifié dans l'assistant pour ce cas précis.
- Masquer des lignes. Certaines lignes peuvent être marquées comme Ne pas imprimer (dans l'ensemble : Masqué). Elles comptent dans le total, mais ne sont ni imprimées ni affichées dans le portail client. L'indicateur passe du devis à la facture.
- Prix fixe par ligne d'ensemble. Une ligne d'ensemble peut porter un prix unitaire fixe qui remplace le prix de la liste de prix, par exemple pour une correction de prix masquée.
Sans le module (uniquement sale_product_set) | Avec sale_product_set_layout |
|---|---|
| Lignes de l'ensemble sous forme de positions isolées | Ligne de section avec le nom de l'ensemble, quantité dans le nom, lignes en dessous |
| Les sections de l'ensemble restent des sections et brisent la structure | Les sections de l'ensemble deviennent des sous-sections (line_subsection) |
| Aspect à l'impression toujours identique | Mode d'impression par ensemble, modifiable lors de l'insertion |
| Correction de prix visible sur le devis et la facture | Ligne masquable, mais comptée dans le total |
| Prix uniquement issu de la liste de prix | Prix fixe optionnel par ligne d'ensemble |
| La mise en page s'arrête au devis | La mise en page et le masquage s'appliquent au devis, à la commande, à la facture et au portail client |
Les trois modes d'impression en détail
Le mode d'impression est un champ de sélection print_mode sur l'ensemble de produits. Lors de l'insertion, il est traduit en les deux commutateurs standard de la ligne de section. Le module contient pour cela une seule correspondance, facile à lire :
# Mode d'impression -> (collapse_prices, collapse_composition) de la ligne de section
PRINT_MODE_FLAGS = {
"detailed": (False, False),
"total_only": (True, False),
"summary": (False, True),
}
Mode (print_mode) | Affichage dans l'ensemble | Option standard dans Odoo | Aspect à l'impression |
|---|---|---|---|
detailed | Chaque ligne avec son prix | aucune | Section, toutes les lignes avec leur montant |
total_only | Lignes sans prix, uniquement le total de l'ensemble | collapse_prices (Masquer les prix) | Section avec total, lignes uniquement avec désignation et quantité |
summary | Une seule ligne avec le total de l'ensemble | collapse_composition (Masquer la composition) | Uniquement la ligne de section avec le total |
Comme le module utilise les options standard d'Odoo 19 et non un format d'impression propre, le devis, la confirmation de commande, la facture et le portail client fonctionnent sans modèles de rapport personnalisés. Ceux qui disposent de leurs propres modèles de rapport tenant compte des options de section obtiennent également ce comportement.
Lignes masquées et prix fixe
Un cas fréquent : un pack a un prix global inférieur à la somme des prix unitaires. Plutôt que de déformer les prix unitaires, on utilise une ligne de correction à prix fixe (par exemple −50,00 € par ensemble) et on la masque. Dans le back-office, la facture reste traçable ; sur l'impression figure le prix du pack.
Important pour la compréhension : l'indicateur hide_in_report ne modifie rien au calcul. Quantité, prix, remise, taxe, totaux et écritures comptables sont produits sans changement par Odoo standard. La ligne manque seulement sur l'impression et dans le portail.
- Sur l'ensemble de produits : ligne d'ensemble avec Masqué et, en option, Prix fixe avec Prix unitaire.
- Sur les lignes de devis et de facture : colonne Ne pas imprimer (
hide_in_report), également pour les lignes normales sans ensemble. - Transmission : lors de la création de la facture à partir de la commande, l'indicateur est reporté sur la ligne de facture. Lors de la copie d'un devis, il est conservé.
Mise en œuvre technique
Le module est volontairement petit : il étend des modèles existants et ne remplace que les endroits où Odoo décide quelles lignes sont imprimées et comment un ensemble est inséré. Il n'y a ni nouveaux modèles de rapport, ni nouveaux modèles de données, ni interventions SQL.
Modèles et champs étendus
| Modèle | Ajout |
|---|---|
product.set | Champs insert_as_section (booléen, activé par défaut) et print_mode (sélection, obligatoire, valeur par défaut detailed) |
product.set.line | Champs hide_in_report, fixed_price, price_unit ; prepare_sale_order_line_values() les transmet à la ligne de devis |
sale.product.set.wizard | Champs insert_as_section, section_name, print_mode ; _prepare_order_lines() construit la ligne de section |
sale.order / sale.order.line | Champ hide_in_report (avec copy=True) ; _get_order_lines_to_report() ignore les lignes masquées ; _prepare_invoice_line() transmet l'indicateur |
account.move / account.move.line | Champ hide_in_report ; _get_move_lines_to_report(), _get_detailed_lines() et _get_subsection_lines() filtrent les lignes masquées ; _get_aml_vals() reporte l'indicateur dans les données du rapport |
Masquage dans le devis
Odoo 19 détermine les lignes de devis à imprimer dans _get_order_lines_to_report(). Le module y ajoute un filtre. Aucune intervention dans le modèle de rapport n'est nécessaire : le modèle de rapport du devis (sale/report/ir_actions_report_templates.xml) comme le modèle du portail (sale/views/sale_portal_templates.xml) appellent cette méthode :
class SaleOrder(models.Model):
_inherit = "sale.order"
def _get_order_lines_to_report(self):
return super()._get_order_lines_to_report().filtered(
lambda line: not line.hide_in_report
)
Transmission à la facture
Lors de la création de la ligne de facture à partir de la ligne de devis (_prepare_invoice_line()), l'indicateur est copié. Côté facture, Odoo 19 propose plusieurs chemins par lesquels les lignes arrivent dans le rapport : la liste principale, les lignes d'une section et les lignes d'une sous-section. C'est pourquoi le module filtre à plusieurs endroits (_get_move_lines_to_report() sur la pièce, _get_detailed_lines() et _get_subsection_lines() sur la ligne), faute de quoi une ligne masquée pourrait réapparaître par l'un des autres chemins :
class AccountMoveLine(models.Model):
_inherit = "account.move.line"
hide_in_report = fields.Boolean(string="Hidden in report")
def _get_aml_vals(self, hide_taxes):
res = super()._get_aml_vals(hide_taxes)
res["hide_in_report"] = self.hide_in_report
return res
def _get_detailed_lines(self, children_lines, direct_children_lines, subsection_lines):
res = super()._get_detailed_lines(children_lines, direct_children_lines, subsection_lines)
return [vals for vals in res if not vals.get("hide_in_report")]
def _get_subsection_lines(self, subsection_line, children_lines):
res = super()._get_subsection_lines(subsection_line, children_lines)
return [vals for vals in res if not vals.get("hide_in_report")]
L'assistant : une section au lieu de lignes isolées
L'assistant sale.product.set.wizard de sale_product_set génère les lignes de devis dans _prepare_order_lines(). Le module remplace cette méthode. Si Insérer sous forme de section est désactivé, il appelle simplement la méthode d'origine et le comportement reste celui du module OCA. Sinon, la ligne de section avec les deux commutateurs standard est créée en premier, puis viennent les lignes de l'ensemble. Les sections à l'intérieur de l'ensemble deviennent des sous-sections afin de ne pas briser la structure principale :
def _prepare_order_lines(self):
if not self.insert_as_section:
return super()._prepare_order_lines()
sequence = self._get_max_sequence() + 1
collapse_prices, collapse_composition = PRINT_MODE_FLAGS[self.print_mode or "detailed"]
order_lines = [Command.create({
"display_type": "line_section",
"name": self.section_name or self.product_set_id.name,
"sequence": sequence,
"collapse_prices": collapse_prices,
"collapse_composition": collapse_composition,
})]
for set_line in self._get_lines():
sequence += 1
values = set_line.prepare_sale_order_line_values(self.order_id, self.quantity, sequence)
if values.get("display_type") == "line_section":
values["display_type"] = "line_subsection"
order_lines.append(Command.create(values))
return order_lines
Des décisions que l'on ne voit pas
- Méthodes de calcul distinctes dans l'assistant. Le nom de la section, le mode d'impression et Insérer sous forme de section ont chacun leur propre méthode
compute(store=True,readonly=False). Si les trois partageaient la même méthode, une valeur transmise explicitement pour un champ réinitialiserait les autres. Cela se remarque surtout lorsque l'assistant est créé avec des valeurs depuis du code ou des tests. - Nom de section avec quantité. Pour une quantité différente de 1, elle figure dans le nom (2 × Kit de mesure Standard), afin que l'on reconnaisse sur l'impression qu'il s'agit de deux ensembles et non d'un ensemble en quantité double.
- Prix fixe uniquement sur les lignes de produit.
prepare_sale_order_line_values()ne définithide_in_reportetprice_unitque si la ligne de l'ensemble n'est pas une ligne de section ou de note. - Odoo standard plutôt que développement maison. Le mode d'impression et le masquage utilisent les hooks et champs existants d'Odoo 19. Cela rend les mises à jour maîtrisables : si Odoo modifie le modèle de rapport, le module reste valide tant que les méthodes existent.
Installation sans effets secondaires indésirables
Prérequis : Odoo 19.0, sale_product_set (OCA/sale-workflow, branche 19.0) et product_set (OCA/product-attribute, branche 19.0). Le module lui-même dépend de sale_management, sale_product_set et account.
Le piège de l'auto_install avec les modules OCA. De nombreux dépôts OCA contiennent des modules avec auto_install. Dès que leurs dépendances sont présentes, Odoo les installe sans rien demander lors de la prochaine installation ou mise à jour. Quiconque place donc le dépôt OCA complet dans l'addons_path d'une instance de production risque d'obtenir des modules que personne ne voulait. Avec OCA/product-attribute, cela concerne par exemple purchase_product_template_tags. Notre méthode :
- Cloner superficiellement les deux dépôts OCA (
git clone --depth 1 --branch 19.0). - Dans un répertoire dédié, ne lier par lien symbolique que les deux modules nécessaires et ajouter ce répertoire à l'
addons_path, et non les dépôts entiers. - Installer sur une base de test et vérifier quels modules obtiennent alors le statut installé.
- Ce n'est qu'ensuite qu'on installe en production, avec
--workers=0, afin que l'appel n'entre pas en concurrence avec le service en cours pour le port HTTP.
odoo-bin -c /etc/odoo/odoo.conf -d <datenbank> -i sale_product_set_layout --stop-after-init
Les nouveaux champs et vues sont dans la base de données après l'installation. Si le service fonctionne en mode multiprocessus, les workers rechargent le registre modifié via le signal de la base de données. Les modifications des fichiers Python du module ne prennent effet qu'après un redémarrage du service.
Utilisation pas à pas
- Créer un ensemble : Ventes → Produits → Ensembles de produits. Nommer l'ensemble, choisir Insérer sous forme de section et Mode d'impression, saisir les produits avec leurs quantités. Doter les lignes de correction de Masqué et de Prix fixe.
- Dans le devis : cliquer sur Ajouter un ensemble, choisir l'ensemble et la quantité. Le nom de la section et le mode d'impression peuvent être modifiés avant l'insertion.
- Masquer certaines lignes : dans le devis ou la facture, activer la colonne Ne pas imprimer.
- Vérifier : ouvrir l'aperçu ou le PDF. Les clients voient la même présentation dans le portail.
Tests
Le module est livré avec six tests automatisés que nous exécutons sur une base de test avant toute mise en production :
- Insertion sous forme de section, mode d'impression et sous-section compris
- La ligne masquée est absente des lignes à imprimer
- Mode Une ligne (
summary) - Insertion sans section, le comportement d'origine
- La facture reprend l'indicateur
hide_in_report, la ligne est absente de_get_move_lines_to_report(), le total de la section la contient toujours - La facture en mode Prix total uniquement n'affiche pas la ligne masquée dans les lignes de la section, le total de la section reste complet
odoo-bin -d <testdatenbank> -i sale_product_set_layout \
--test-enable --test-tags=/sale_product_set_layout --stop-after-init
La traduction allemande se trouve dans i18n/de.po.
Limites et mention juridique
- Mise en page uniquement. Le module ne modifie pas la façon dont Odoo calcule les prix, remises ou taxes. Il ne modifie que ce qui est imprimé et affiché dans le portail.
- Les lignes masquées sont visibles dans le back-office. L'indicateur concerne l'impression et le portail, pas l'affichage à l'écran des collaborateurs.
- Vérifier les mentions de facture. Les lignes masquées comptent dans le total, mais ne figurent pas sur le document. Il convient de clarifier avec votre conseiller fiscal, avant toute utilisation sur des factures, si cela est compatible avec les exigences applicables aux factures dans votre pays, par exemple la description obligatoire des biens livrés ou des services fournis. Sur les devis, le masquage est moins problématique. Cet article ne constitue pas un conseil juridique.
- Testé pour Odoo 19. Nous avons développé et testé le module pour Odoo 19.0 Community. Il utilise des options de section qui existent sous cette forme dans Odoo 19. Nous ne l'avons pas vérifié pour d'autres versions.
Questions fréquentes
Pourquoi ai-je besoin du module si Odoo 19 gère déjà les sections ?
Les options de section existent dans Odoo lui-même. Ce qui manquait, c'était le lien avec l'ensemble de produits : un ensemble devrait être inséré automatiquement sous forme de section, avec le mode d'impression défini dans l'ensemble. Sans le module, il faut créer et configurer la ligne de section à la main après chaque insertion.
Quelle est la différence entre « Masquer les prix » et « Masquer la composition » ?
« Masquer les prix » (collapse_prices) laisse les lignes en place mais n'affiche aucun montant, seulement le total de la section. « Masquer la composition » (collapse_composition) masque complètement les lignes ; le client ne voit que la section avec le total.
Une ligne masquée compte-t-elle dans le total de la facture ?
Oui. Les lignes masquées restent dans le total, la taxe et la comptabilisation. L'indicateur ne concerne que la présentation sur l'impression et dans le portail client.
Puis-je encore modifier le mode d'impression lors de l'insertion ?
Oui. Le mode est proposé par défaut à partir de l'ensemble ; dans l'assistant, il peut être modifié pour l'insertion en cours. L'ensemble lui-même reste inchangé.
Que se passe-t-il si je désactive Insérer sous forme de section ?
L'assistant se comporte alors comme dans le module OCA sale_product_set : les lignes de l'ensemble sont insérées sans section.
Le module installe-t-il d'autres modules de façon inattendue ?
Le module lui-même, non. Avec les dépôts OCA, des modules auto_install peuvent toutefois apparaître si l'on place des dépôts entiers dans l'addons_path. Ne liez donc que les deux modules nécessaires (voir la section Installation).
Le module fonctionne-t-il avec Odoo Enterprise ?
Le module ne dépend que de modules Community (sale_management, account) et des deux modules OCA. Nous l'utilisons sur Odoo 19 Community. Nous ne l'avons pas testé sur Enterprise.
Sous quelle licence le module est-il distribué ?
Sous AGPL-3. Vous pouvez l'utiliser, l'adapter et le redistribuer, tant que les modifications restent sous la même licence. Les erreurs et suggestions d'amélioration sont les bienvenues sous forme d'issue dans le dépôt GitHub .
Assistance de Biricon IT Services
Nous gérons des installations Odoo pour les petites et moyennes entreprises : de la mise en place aux adaptations et migrations, jusqu'à l'exploitation courante. Si vous souhaitez adapter des ensembles de produits, des modèles de devis ou des rapports dans Odoo 19 à vos processus, ou si vous voulez examiner un module comme celui-ci et l'installer en toute sécurité dans votre instance de production, nous sommes à votre disposition. Vous trouverez d'autres développements internes dans notre blog, par exemple Fusionner des brouillons de factures et Rapprocher automatiquement les paiements bancaires et les factures.
Situation au 2 novembre 2026. Version du module 19.0.1.0.0. Les données d'exemple des illustrations sont choisies librement, montants nets.