Se rendre au contenu

Un rapport Excel multi-feuilles avec sommaire dans Odoo 19

Série Rapports Excel · Article 3/6 — Développement Odoo
25 juin 2026 par
Un rapport Excel multi-feuilles avec sommaire dans Odoo 19
| Aucun commentaire pour l'instant

Série Rapports Excel · Article 3/6

Un rapport Excel multi-feuilles avec sommaire dans Odoo 19

Les deux premiers articles produisaient une seule feuille soignée. Place au classeur à plusieurs feuilles : une feuille par commercial, une feuille Sommaire en tête qui mène à chacune par un lien interne, et un total par feuille — toujours avec la seule bibliothèque xlsxwriter embarquée dans Odoo.

~12 minutes de lecture · niveau intermédiaire · parcours Framework & ORM

Ce que tu vas apprendre

Plusieurs feuilles

Regrouper les enregistrements en Python, puis créer une feuille add_worksheet par groupe avec un nom d'onglet valide.

Une feuille sommaire

Bâtir une feuille d'accueil qui récapitule chaque groupe et son total, créée en premier pour rester en tête des onglets.

Des liens internes

Relier le sommaire aux feuilles de détail avec write_url et la cible internal:, comme un vrai tableau de bord cliquable.

D'une feuille à un classeur structuré

Dès qu'un rapport doit séparer les données par dimension — par commercial, par société, par mois — entasser le tout sur une feuille unique devient illisible. Un classeur Excel accepte autant de feuilles que nécessaire : workbook.add_worksheet(nom) en crée une et renvoie l'objet feuille. La difficulté n'est pas technique, elle est d'organisation : nommer correctement les onglets, calculer un total par feuille, et offrir une porte d'entrée unique vers l'ensemble.

Tout tient dans une méthode _build_sale_lines_xlsx_sheets posée sur sale.order, secondée par deux méthodes privées qui remplissent respectivement une feuille de détail et la feuille sommaire. On la construit bloc par bloc.

1. Regrouper les commandes par commercial

Avant de dessiner quoi que ce soit, on répartit le recordset en groupes. Un simple dictionnaire dont les valeurs sont des recordsets sale.order suffit : l'union |= accumule les commandes du même commercial. On trie ensuite par nom pour un ordre d'onglets stable.

        # --- Regroupement des commandes par commercial ---
        groups = {}
        for order in self:
            commercial = order.user_id.name or _('Sans commercial')
            groups.setdefault(commercial, self.env['sale.order'])
            groups[commercial] |= order
        # Ordre stable : alphabétique sur le nom du commercial.
        ordered = sorted(groups.items(), key=lambda kv: kv[0].lower())

Travailler avec des recordsets plutôt que des listes d'id garde tout l'ORM à portée de main dans chaque feuille : on réutilisera order.partner_id ou line.price_subtotal sans relire la base.

2. Le sommaire d'abord, puis les feuilles de détail

Détail d'ergonomie qui compte : dans xlsxwriter, l'ordre des onglets suit l'ordre de création des feuilles. Pour que le sommaire reste en tête, on le crée en premier — mais on le remplit en dernier, une fois les totaux par feuille connus. On crée donc la feuille sommaire vide, on l'active, puis on génère les feuilles de détail.

        # --- Feuille sommaire créée EN PREMIER : l'ordre de création fixe ---
        # l'ordre des onglets dans xlsxwriter, donc le sommaire reste en tête.
        # On la remplit après coup, une fois les totaux par feuille connus.
        summary = workbook.add_worksheet(_('Sommaire'))
        summary.activate()

        # --- Une feuille de détail par commercial ---
        used_names = {_('sommaire')}  # réserve le nom de la feuille d'accueil
        summary_rows = []  # (sheet_name, nb_commandes, total_ht)
        for commercial, orders in ordered:
            sheet_name = self._safe_sheet_name(commercial, used_names)
            sheet = workbook.add_worksheet(sheet_name)
            total = self._fill_detail_sheet(
                sheet, commercial, orders, headers,
                title_fmt, header_fmt, text_fmt, date_fmt, money_fmt,
                total_lbl_fmt, total_money_fmt,
            )
            summary_rows.append((sheet_name, len(orders), total))

Un nom de feuille Excel obéit à des règles strictes : 31 caractères maximum, sans les caractères [ ] : * ? / \, et unique dans le classeur. Le nom d'un commercial peut violer chacune de ces règles. On le passe donc par un nettoyeur dédié, qui réserve aussi le nom « Sommaire » pour éviter toute collision.

    @staticmethod
    def _safe_sheet_name(name, used):
        """Nettoie un nom de feuille Excel : 31 car. max, sans []:*?/\\ et unique."""
        clean = re.sub(r'[\[\]:*?/\\]', ' ', name or _('Sans commercial')).strip()
        clean = (clean or _('Sans commercial'))[:31]
        candidate, n = clean, 1
        while candidate.lower() in used:
            n += 1
            suffix = ' (%d)' % n
            candidate = clean[:31 - len(suffix)] + suffix
        used.add(candidate.lower())
        return candidate

La troncature tient compte du suffixe : si deux commerciaux portent un nom identique après nettoyage, le second devient … (2) sans jamais dépasser 31 caractères.

3. Remplir une feuille de détail

Chaque feuille de détail reprend la recette des articles précédents : un titre fusionné, une ligne d'en-tête, des données écrites avec le bon type, et une ligne de total par formule. La méthode renvoie le total HT du groupe, qui alimentera le sommaire.

    def _fill_detail_sheet(self, sheet, commercial, orders, headers, ...):
        """Remplit une feuille de détail pour un commercial. Renvoie le total HT."""
        last_col = len(headers) - 1
        sheet.merge_range(
            0, 0, 0, last_col,
            _('Commandes — %s') % commercial, title_fmt,
        )
        sheet.set_row(0, 24)
        for col, label in enumerate(headers):
            sheet.write(1, col, label, header_fmt)
        widths = [len(h) for h in headers]

        row = 2
        total_ht = 0.0
        for order in orders:
            for line in order.order_line.filtered(lambda l: not l.display_type):
                total_ht += line.price_subtotal
                date = order.date_order.date() if order.date_order else None
                values = [
                    order.name, date, order.partner_id.display_name,
                    line.product_id.display_name or line.name,
                    line.product_uom_qty, line.price_subtotal,
                ]
                sheet.write(row, 0, values[0], text_fmt)
                sheet.write_datetime(row, 1, date, date_fmt) if date else sheet.write(row, 1, '', text_fmt)
                sheet.write(row, 2, values[2], text_fmt)
                sheet.write(row, 3, values[3], text_fmt)
                sheet.write_number(row, 4, values[4], text_fmt)
                sheet.write_number(row, 5, values[5], money_fmt)
                for col, value in enumerate(values):
                    widths[col] = max(widths[col], len(str(value or '')))
                row += 1

La ligne de total ferme la feuille avec une formule =SUM(...) dont on passe la valeur en cache via value= — sans quoi le tableur afficherait 0 avant recalcul, un piège déjà rencontré à l'article précédent.

        first_data_row, last_data_row = 2, row - 1
        if last_data_row >= first_data_row:
            sheet.write(row, 0, _('Total'), total_lbl_fmt)
            for col in range(1, last_col):
                sheet.write_blank(row, col, None, total_lbl_fmt)
            sheet.write_formula(
                row, last_col,
                '=SUM(F%d:F%d)' % (first_data_row + 1, last_data_row + 1),
                total_money_fmt, value=total_ht,
            )

        for col, width in enumerate(widths):
            sheet.set_column(col, col, min(width + 2, 50))
        sheet.freeze_panes(2, 0)

        # Impression : paysage, largeur ajustée, titre + en-tête répétés.
        sheet.set_landscape()
        sheet.fit_to_pages(1, 0)
        sheet.repeat_rows(0, 1)
        return total_ht
Feuille de détail Excel pour un commercial : titre fusionné vert, en-tête figé, lignes de commandes typées et ligne de total HT par formule
Une feuille de détail par commercial : titre, en-tête figé, données typées et total par formule.

4. La feuille sommaire et ses liens internes

Le sommaire est le cœur de l'article. Pour chaque groupe, il affiche le nom du commercial, son nombre de commandes et son total — le nom étant un lien cliquable vers sa feuille. C'est write_url avec une cible internal: qui crée ce lien interne au classeur ; sa syntaxe reprend la notation Excel 'Nom feuille'!A1, apostrophes internes doublées comprises.

Un seul format s'ajoute à la palette héritée des articles précédents : le style de lien, vert et souligné.

        link_fmt = workbook.add_format({'font_color': '#1D6F42', 'underline': 1, 'border': 1})
    def _fill_summary_sheet(self, sheet, summary_rows, ...):
        """Feuille d'accueil : une ligne par feuille de détail, avec lien interne."""
        cols = [_('Commercial'), _('Commandes'), _('Total HT')]
        sheet.merge_range(0, 0, 0, len(cols) - 1, _('Sommaire par commercial'), title_fmt)
        sheet.set_row(0, 24)
        for col, label in enumerate(cols):
            sheet.write(1, col, label, header_fmt)

        row = 2
        grand_total = 0.0
        for sheet_name, nb_orders, total in summary_rows:
            grand_total += total
            # Lien interne : `internal:'Nom feuille'!A1`. Les apostrophes
            # internes au nom sont doublées, comme dans une formule Excel.
            target = "internal:'%s'!A1" % sheet_name.replace("'", "''")
            sheet.write_url(row, 0, target, link_fmt, sheet_name)
            sheet.write_number(row, 1, nb_orders, text_fmt)
            sheet.write_number(row, 2, total, money_fmt)
            row += 1

        if summary_rows:
            sheet.write(row, 0, _('Total général'), total_lbl_fmt)
            sheet.write_blank(row, 1, None, total_lbl_fmt)
            sheet.write_number(row, 2, grand_total, total_money_fmt)

        sheet.set_column(0, 0, 28)
        sheet.set_column(1, 1, 12)
        sheet.set_column(2, 2, 16)
        sheet.freeze_panes(2, 0)

Le quatrième argument de write_url est le texte affiché : on y remet le nom de feuille, pour que la cellule lise « Marc Demo » et non l'adresse technique. La dernière ligne agrège un total général en sommant les totaux déjà calculés par feuille — pas besoin de relire toutes les lignes.

Feuille Sommaire d'un classeur Excel Odoo : tableau Commercial / Commandes / Total HT, noms en liens internes soulignés et ligne Total général
La feuille Sommaire : chaque commercial est un lien interne vers sa feuille, avec son total et le total général.

5. Le controller et le bouton

Le reste est identique aux articles précédents : un controller HTTP réservé aux utilisateurs connectés sert les octets du classeur, et un bouton d'en-tête sur sale.order redirige vers cette route via une action ir.actions.act_url.

    @route('/odooskills/export/sale_lines/xlsx_sheets', type='http', auth='user', readonly=True)
    def export_sale_lines_xlsx_sheets(self, order_ids=None, **kw):
        ids = [int(x) for x in (order_ids or '').split(',') if x.strip().isdigit()]
        orders = request.env['sale.order'].browse(ids).exists()
        content = orders._build_sale_lines_xlsx_sheets()
        headers = [
            ('Content-Type',
             'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),
            ('Content-Disposition', content_disposition('rapport_ventes_multi_feuilles.xlsx')),
        ]
        return request.make_response(content, headers)

Sélectionne plusieurs commandes dans la liste, ou ouvre-en une, et le bouton produit un classeur dont les onglets reflètent les commerciaux présents.

En-tête d'une commande de vente Odoo 19 avec le bouton Exporter en Excel (multi-feuilles) aux côtés des boutons des articles précédents
Le bouton « Exporter en Excel (multi-feuilles) » rejoint ceux des deux premiers articles dans l'en-tête.

⚠️ Pièges à éviter

  • L'ordre des onglets suit l'ordre de création des feuilles : pour garder le sommaire en tête, le créer en premier et le remplir ensuite.
  • Un nom de feuille dépassant 31 caractères ou contenant [ ] : * ? / \ fait échouer la génération : nettoyer systématiquement.
  • Deux feuilles ne peuvent pas porter le même nom : prévoir un suffixe d'unicité.
  • Pour un lien interne, c'est write_url(..., "internal:'Feuille'!A1") — le préfixe internal: est obligatoire, et les apostrophes du nom se doublent comme dans une formule.
  • Calculer le total général en resommant les totaux par feuille évite de reparcourir toutes les lignes une seconde fois.

À retenir

  • Regrouper le recordset en Python, puis une feuille add_worksheet par groupe : un classeur multi-feuilles n'est qu'une boucle bien ordonnée.
  • Créer la feuille sommaire en premier la maintient en tête des onglets ; la remplir en dernier donne accès aux totaux déjà calculés.
  • write_url avec une cible internal: transforme le sommaire en tableau de bord cliquable vers chaque feuille de détail.

Télécharge le Guide Technique Odoo

Un module Odoo 19 fonctionnel, 20+ articles techniques, environnements de dev et pipeline complet — le tout en PDF.

Télécharger le guide

À lire également

Se connecter pour laisser un commentaire.
Mettre en forme un rapport Excel avancé dans Odoo 19
Série Rapports Excel · Article 2/6 — Développement Odoo