Se rendre au contenu

Odoo 19 Community : ensembles de produits dans le devis sous forme de section – notre module open source Sale Product Set Layout

2 novembre 2026 | Odoo 19 Community | Développement interne
2 novembre 2026 par
Odoo 19 Community : ensembles de produits dans le devis sous forme de section – notre module open source Sale Product Set Layout

Module Odoo 19 Sale Product Set Layout : ensembles de produits sous forme de section dans le devis avec mode d'impression, lignes masquées et prix fixe

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

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

  1. 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.
  2. 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.
  3. 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.
  4. 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éesLigne 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 structureLes sections de l'ensemble deviennent des sous-sections (line_subsection)
Aspect à l'impression toujours identiqueMode d'impression par ensemble, modifiable lors de l'insertion
Correction de prix visible sur le devis et la factureLigne masquable, mais comptée dans le total
Prix uniquement issu de la liste de prixPrix fixe optionnel par ligne d'ensemble
La mise en page s'arrête au devisLa 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),
}

Trois aspects d'impression du même ensemble de produits : chaque ligne avec son prix, prix total uniquement, une seule ligne

Mode (print_mode)Affichage dans l'ensembleOption standard dans OdooAspect à l'impression
detailedChaque ligne avec son prixaucuneSection, toutes les lignes avec leur montant
total_onlyLignes sans prix, uniquement le total de l'ensemblecollapse_prices (Masquer les prix)Section avec total, lignes uniquement avec désignation et quantité
summaryUne seule ligne avec le total de l'ensemblecollapse_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.

Correction de prix visible et signalée dans le back-office, invisible sur l'impression du devis, du PDF, de la facture et du portail, total identique

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.

Flux de données : ensemble de produits, assistant Ajouter un ensemble, lignes de devis, lignes de facture, PDF et portail client

Modèles et champs étendus

ModèleAjout
product.setChamps insert_as_section (booléen, activé par défaut) et print_mode (sélection, obligatoire, valeur par défaut detailed)
product.set.lineChamps hide_in_report, fixed_price, price_unit ; prepare_sale_order_line_values() les transmet à la ligne de devis
sale.product.set.wizardChamps insert_as_section, section_name, print_mode ; _prepare_order_lines() construit la ligne de section
sale.order / sale.order.lineChamp 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.lineChamp 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éfinit hide_in_report et price_unit que 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 :

  1. Cloner superficiellement les deux dépôts OCA (git clone --depth 1 --branch 19.0).
  2. 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.
  3. Installer sur une base de test et vérifier quels modules obtiennent alors le statut installé.
  4. 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

  1. 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.
  2. 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.
  3. Masquer certaines lignes : dans le devis ou la facture, activer la colonne Ne pas imprimer.
  4. 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.

Nous contacter

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.