Se rendre au contenu

Mettre en forme un rapport Excel avancé dans Odoo 19

Série Rapports Excel · Article 2/6 — Développement Odoo
25 juin 2026 par
Mettre en forme un rapport Excel avancé dans Odoo 19
| Aucun commentaire pour l'instant

Série Rapports Excel · Article 2/6

Mettre en forme un rapport Excel avancé dans Odoo 19

Le premier article produisait un fichier .xlsx correct mais brut. Place à la mise en forme professionnelle : palette de formats réutilisables, titre fusionné, volets figés, formats monétaires, pourcentages et dates, formatage conditionnel et ligne de totaux par formule — toujours avec la seule bibliothèque xlsxwriter embarquée.

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

Ce que tu vas apprendre

Une palette de formats

Déclarer une fois des objets add_format — en-têtes, monétaire, pourcentage, date — et les réutiliser sur chaque cellule.

La structure lisible

Un titre fusionné sur toute la largeur, des volets figés sous l'en-tête et une mise en page prête à imprimer.

Les signaux visuels

Un dégradé de couleur conditionnel sur les montants et une ligne de totaux calculée par une vraie formule Excel.

Du tableau brut au rapport présentable

Le rapport de l'article précédent affichait des en-têtes verts et un format monétaire — suffisant pour dépanner, trop pauvre pour un document transmis à la direction. La mise en forme avancée d'xlsxwriter repose sur un principe simple : un format est un objet, créé une fois par workbook.add_format(...), puis passé en dernier argument de chaque écriture de cellule. Déclarer la palette en tête de méthode évite de recréer les mêmes formats en boucle — un format réutilisé reste un seul objet dans le fichier final.

Tout le travail de cet article tient dans une seule méthode, _build_sale_lines_xlsx_pro, posée sur sale.order. Elle est servie par un controller jumeau de celui du premier article et déclenchée par un second bouton d'en-tête. On la construit bloc par bloc.

1. La palette de formats

Chaque add_format reçoit un dictionnaire de propriétés. On prépare huit formats : un pour le titre, un pour l'en-tête, un format texte bordé, puis les formats spécialisés (date, monétaire, pourcentage) et deux variantes pour la ligne de totaux.

        # --- Palette de formats réutilisables ---
        title_fmt = workbook.add_format({
            'bold': True, 'font_size': 14, 'font_color': '#FFFFFF',
            'bg_color': '#1D6F42', 'align': 'center', 'valign': 'vcenter',
        })
        header_fmt = workbook.add_format({
            'bold': True, 'font_color': '#FFFFFF', 'bg_color': '#2E7D54',
            'border': 1, 'align': 'center', 'valign': 'vcenter', 'text_wrap': True,
        })
        text_fmt = workbook.add_format({'border': 1})
        date_fmt = workbook.add_format({'num_format': 'dd/mm/yyyy', 'border': 1})
        money_fmt = workbook.add_format({'num_format': '#,##0.00\\ €', 'border': 1})
        pct_fmt = workbook.add_format({'num_format': '0.0"%"', 'border': 1})
        total_lbl_fmt = workbook.add_format({
            'bold': True, 'bg_color': '#E8F3EC', 'border': 1, 'align': 'right',
        })
        total_money_fmt = workbook.add_format({
            'bold': True, 'bg_color': '#E8F3EC', 'num_format': '#,##0.00\\ €', 'border': 1,
        })

La clé décisive est num_format : c'est la chaîne de format de nombre d'Excel, identique à celle de la boîte « Format de cellule ». Quelques motifs utiles :

Objectifnum_formatRendu
Monétaire euro#,##0.00\ €1 280,00 €
Pourcentage littéral0.0"%"10,0%
Date courtedd/mm/yyyy25/06/2026
Entier séparé#,##012 000

Attention à la nuance du pourcentage : le format Excel natif 0.0% multiplie la valeur par 100. Comme le champ discount d'Odoo vaut déjà 10 pour « 10 % », on écrit la valeur telle quelle avec un format littéral 0.0"%" qui se contente d'accoler le signe, sans recalcul.

2. Titre fusionné, en-tête et volets figés

La première ligne porte un titre fusionné sur toute la largeur via merge_range, dont les arguments sont les coordonnées du coin haut-gauche et du coin bas-droit. La deuxième ligne reçoit les libellés de colonnes.

        headers = [
            _('Commande'), _('Date'), _('Client'), _('Produit'),
            _('Quantité'), _('Prix unitaire'), _('Remise %'), _('Total HT'),
        ]

        # --- Ligne 0 : titre fusionné sur toute la largeur ---
        last_col = len(headers) - 1
        sheet.merge_range(
            0, 0, 0, last_col,
            _('Rapport des lignes de vente — %s') % self.env.company.name,
            title_fmt,
        )
        sheet.set_row(0, 24)

        # --- Ligne 1 : en-têtes de colonnes ---
        for col, label in enumerate(headers):
            sheet.write(1, col, label, header_fmt)
        widths = [len(h) for h in headers]

Le nom de société vient de self.env.company.name — le rapport s'adapte automatiquement à la société courante. set_row(0, 24) donne de la hauteur au bandeau de titre. On gardera plus loin un freeze_panes(2, 0) pour figer ces deux premières lignes : en défilant, tu gardes titre et en-tête sous les yeux.

3. Des données écrites avec le bon type

Plutôt que le write générique, on emploie les méthodes typées d'xlsxwriter : write_number pour les montants et quantités, write_datetime pour la date. Chaque appel reçoit le format adapté. On cumule au passage le total HT, dont on aura besoin pour la formule.

        # --- Lignes de données ---
        row = 2
        total_ht = 0.0
        for order in self:
            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_unit,
                    line.discount,
                    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)
                sheet.write_number(row, 6, values[6], pct_fmt)
                sheet.write_number(row, 7, values[7], money_fmt)
                for col, value in enumerate(values):
                    widths[col] = max(widths[col], len(str(value or '')))
                row += 1

        first_data_row, last_data_row = 2, row - 1

Écrire une date passe par write_datetime et un objet date/ datetime Python : c'est ce qui permet à Excel de la traiter comme une vraie date (tri chronologique, calculs), pas comme du texte. Le filtre filtered(lambda l: not l.display_type) écarte toujours les lignes de section et de note, dépourvues de quantité et de prix.

4. Totaux par formule et formatage conditionnel

La ligne de totaux n'écrit pas un nombre figé : elle pose une vraie formule =SUM(...), qui se recalcule si l'utilisateur modifie une cellule. Le piège classique : xlsxwriter n'évalue pas les formules, et un tableur ouvert sans recalcul afficherait 0. On fournit donc la valeur en cache via l'argument value=.

        # --- Ligne de totaux : libellé + formule SUM ---
        if last_data_row >= first_data_row:
            sheet.write(row, 0, _('Total'), total_lbl_fmt)
            for col in range(1, 7):
                sheet.write_blank(row, col, None, total_lbl_fmt)
            # On passe aussi la valeur calculée (`value=`) : xlsxwriter ne
            # calcule pas les formules, le tableur afficherait 0 jusqu'au
            # premier recalcul sans cette valeur en cache.
            sheet.write_formula(
                row, 7,
                '=SUM(H%d:H%d)' % (first_data_row + 1, last_data_row + 1),
                total_money_fmt,
                value=total_ht,
            )

            # --- Formatage conditionnel : dégradé sur la colonne Total HT ---
            sheet.conditional_format(
                first_data_row, 7, last_data_row, 7,
                {'type': '3_color_scale',
                 'min_color': '#FFFFFF', 'mid_color': '#A8D5BA', 'max_color': '#1D6F42'},
            )

Le 3_color_scale applique un dégradé du blanc (plus petit montant) au vert foncé (plus gros) : tu repères d'un coup d'œil les lignes à forte valeur, sans lire un seul chiffre. Les coordonnées de plage suivent la convention Excel 1-based, d'où le + 1 sur les numéros de ligne dans la formule. conditional_format accepte bien d'autres types — cell (seuils), data_bar, icon_set — décrits dans la documentation xlsxwriter.

5. Largeurs, volets et mise en page d'impression

On termine par l'ergonomie : largeurs de colonnes ajustées au contenu, volets figés, puis une mise en page pensée pour l'impression — orientation paysage, ajustement à une page de large, et titre + en-tête répétés en haut de chaque page imprimée.

        # --- Largeurs + volets figés sous l'en-tête ---
        for col, width in enumerate(widths):
            sheet.set_column(col, col, min(width + 2, 50))
        sheet.freeze_panes(2, 0)

        # --- Mise en page pour l'impression : paysage, ajusté à une page ---
        sheet.set_landscape()
        sheet.fit_to_pages(1, 0)  # 1 page de large, hauteur libre
        sheet.repeat_rows(0, 1)   # titre + en-tête répétés à chaque page

        workbook.close()
        data = buffer.getvalue()
        buffer.close()
        return data

fit_to_pages(1, 0) contraint la largeur à une seule page tout en laissant la hauteur libre — c'est lui qui évite que les dernières colonnes débordent sur une feuille fantôme à l'impression. repeat_rows(0, 1) rejoue les deux lignes d'en-tête sur chaque page : indispensable dès que le rapport dépasse une page.

Le résultat

Un clic sur le bouton d'en-tête, et le classeur téléchargé n'a plus rien d'un export brut : titre fusionné, en-tête figé, dates et montants typés, dégradé conditionnel sur les totaux et ligne de total calculée.

Rapport Excel mis en forme : titre fusionné vert, en-têtes, colonnes monétaire/pourcentage/date, dégradé conditionnel sur Total HT et ligne de totaux
Le classeur produit : titre fusionné, formats typés, dégradé conditionnel sur la colonne Total HT et ligne de totaux par formule.

Le second bouton cohabite sans heurt avec celui du premier article — chacun appelle sa propre route et sa propre méthode de génération.

En-tête de commande de vente Odoo 19 avec les boutons Exporter en Excel et Exporter en Excel (mis en forme)
Les deux boutons d'export coexistent dans l'en-tête de la commande de vente.

⚠️ Pièges à éviter

  • Une formule écrite sans value= s'affiche à 0 tant que le tableur n'a pas recalculé : passer la valeur en cache.
  • Le format 0.0% multiplie par 100. Pour une remise Odoo déjà exprimée en points (10 = 10 %), utiliser le format littéral 0.0"%".
  • Écrire une date avec write la transforme en texte. Utiliser write_datetime avec un objet date pour garder un tri et des calculs corrects.
  • Sans fit_to_pages, les colonnes de droite débordent à l'impression sur une page séparée.
  • Recréer un add_format identique dans la boucle gonfle le fichier : déclarer la palette une fois, en dehors.

À retenir

  • Un add_format est un objet réutilisable ; la propriété num_format porte tout le formatage monétaire, pourcentage et date.
  • merge_range, freeze_panes et set_column donnent la structure ; set_landscape/fit_to_pages préparent l'impression.
  • conditional_format ajoute des signaux visuels, et write_formula avec value= pose des totaux recalculables sans afficher 0.

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.
Générer un rapport Excel (XLSX) natif dans Odoo 19
Série Rapports Excel · Article 1/6 — Développement Odoo