Se rendre au contenu

Générer un rapport Excel (XLSX) natif dans Odoo 19

Série Rapports Excel · Article 1/6 — Développement Odoo
25 juin 2026 par
Générer un rapport Excel (XLSX) natif dans Odoo 19
B.Mustapha
| Aucun commentaire pour l'instant

Série Rapports Excel · Article 1/6

Générer un rapport Excel (XLSX) natif dans Odoo 19

Produire un vrai fichier .xlsx téléchargeable depuis les données Odoo, sans aucun module tiers : la bibliothèque xlsxwriter est déjà embarquée par Odoo. En-têtes mis en forme, formats monétaires, largeurs de colonnes automatiques — servis par un controller HTTP.

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

Ce que tu vas apprendre

Le moteur natif

Pourquoi xlsxwriter dans un controller est la voie native en Communauté, et non un report_type="xlsx".

Le code réutilisable

Une méthode modèle qui retourne les octets du classeur — appelable depuis un controller comme depuis un test.

Le déclencheur

Un bouton d'en-tête sur la commande de vente qui télécharge le fichier en un clic.

Native ne veut pas dire « moteur de rapport »

Le réflexe habituel consiste à chercher un ir.actions.report avec un report_type dédié. Pour le PDF, ce moteur existe — c'est le sujet de générer des rapports PDF avec QWeb. Pour le XLSX, il n'existe pas en standard : le fameux report_type="xlsx" provient du module communautaire OCA report_xlsx, pas du cœur d'Odoo.

La voie réellement native en Communauté est plus directe : un controller HTTP qui construit le classeur avec xlsxwriter et le renvoie en pièce jointe. C'est exactement le mécanisme qu'utilise le cœur d'Odoo, par exemple pour l'export d'une liste de prix produit. Le développeur Odoo garde ainsi un contrôle total sur la mise en forme, sans ajouter de dépendance.

Données Odoo
sale.order.line
Bouton d'en-tête
act_url
Controller HTTP
xlsxwriter
Fichier .xlsx
téléchargé

1. Le manifeste

Un module minimal qui dépend de sale et charge une seule vue (le bouton).

{
    'name': 'OdooSkills — Export XLSX natif (démo blog)',
    'version': '19.0.1.0.0',
    'category': 'Tools',
    'summary': "Générer un rapport Excel (.xlsx) natif via xlsxwriter, sans module tiers",
    'license': 'LGPL-3',
    'depends': ['sale'],
    'data': [
        'views/sale_order_views.xml',
    ],
    'installable': True,
    'application': False,
}

Installe le module sur ta base de test :

odoo-bin -u odooskills_export_xlsx -d ma_base --stop-after-init

2. La logique de génération

Le cœur du sujet tient dans une méthode placée sur sale.order. Elle retourne les octets du classeur. La placer sur le modèle — plutôt que dans le controller — la rend réutilisable et, surtout, testable sans serveur HTTP par un simple test automatisé.

import io

from odoo import models, _


class SaleOrder(models.Model):
    _inherit = 'sale.order'

    def _build_sale_lines_xlsx(self):
        """Construit le classeur Excel des lignes des commandes du recordset.

        Retourne les octets (`bytes`) du fichier .xlsx — utilisable depuis un
        controller HTTP comme depuis un test, sans dépendance OCA.
        """
        import xlsxwriter  # vendored par Odoo

        buffer = io.BytesIO()
        workbook = xlsxwriter.Workbook(buffer, {'in_memory': True})
        sheet = workbook.add_worksheet(_('Lignes de vente'))

        header_fmt = workbook.add_format({
            'bold': True, 'bg_color': '#1D6F42', 'font_color': '#FFFFFF', 'border': 1,
        })
        money_fmt = workbook.add_format({'num_format': '#,##0.00'})

        headers = [
            _('Commande'), _('Client'), _('Produit'),
            _('Quantité'), _('Prix unitaire'), _('Total HT'),
        ]
        for col, label in enumerate(headers):
            sheet.write(0, col, label, header_fmt)
        widths = [len(h) for h in headers]

        row = 1
        for order in self:
            for line in order.order_line.filtered(lambda l: not l.display_type):
                cells = [
                    order.name,
                    order.partner_id.display_name,
                    line.product_id.display_name or line.name,
                    line.product_uom_qty,
                    line.price_unit,
                    line.price_subtotal,
                ]
                sheet.write(row, 0, cells[0])
                sheet.write(row, 1, cells[1])
                sheet.write(row, 2, cells[2])
                sheet.write(row, 3, cells[3])
                sheet.write(row, 4, cells[4], money_fmt)
                sheet.write(row, 5, cells[5], money_fmt)
                for col, value in enumerate(cells):
                    widths[col] = max(widths[col], len(str(value)))
                row += 1

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

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

Trois points méritent l'attention. Le tampon io.BytesIO — un fichier en mémoire vive, sans rien écrire sur disque — reçoit le binaire ; l'option {'in_memory': True} renforce ce comportement. Les add_format portent la mise en forme — en-têtes verts, format monétaire #,##0.00. Enfin, le filtre filtered(lambda l: not l.display_type) écarte les lignes de section et de note, qui n'ont ni quantité ni prix.

3. Le controller qui sert le fichier

Le controller ne fait qu'emballer les octets dans une réponse HTTP avec le bon Content-Type et un Content-Disposition qui force le téléchargement.

from odoo.http import Controller, request, route, content_disposition


class OdooSkillsXlsxExport(Controller):

    @route('/odooskills/export/sale_lines/xlsx', type='http', auth='user', readonly=True)
    def export_sale_lines_xlsx(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()
        headers = [
            ('Content-Type',
             'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),
            ('Content-Disposition', content_disposition('rapport_ventes.xlsx')),
        ]
        return request.make_response(content, headers)

Le type de route est http (pas json) car la réponse est un fichier binaire. auth='user' impose une session authentifiée, et readonly=True indique qu'aucune écriture en base n'a lieu — un signal utile pour l'ordonnancement des workers. La fonction content_disposition() s'importe depuis odoo.http et gère correctement l'échappement du nom de fichier.

4. Le déclencheur dans la vue

Une action act_url ouvre la route en passant les identifiants des commandes sélectionnées. Côté modèle :

    def action_export_lines_xlsx(self):
        """Bouton d'en-tête : redirige vers la route de téléchargement du rapport XLSX."""
        ids = ','.join(str(i) for i in self.ids)
        return {
            'type': 'ir.actions.act_url',
            'url': '/odooskills/export/sale_lines/xlsx?order_ids=%s' % ids,
            'target': 'self',
        }

Et le bouton lui-même, greffé dans l'en-tête du formulaire de commande :

<record id="view_order_form_xlsx_export" model="ir.ui.view">
    <field name="name">sale.order.form.xlsx.export</field>
    <field name="model">sale.order</field>
    <field name="inherit_id" ref="sale.view_order_form"/>
    <field name="arch" type="xml">
        <xpath expr="//header" position="inside">
            <button name="action_export_lines_xlsx" type="object"
                    string="Exporter en Excel" class="btn-secondary"/>
        </xpath>
    </field>
</record>
Bouton Exporter en Excel dans l'en-tête d'une commande de vente Odoo 19
Le bouton « Exporter en Excel » apparaît dans l'en-tête de la commande, aux côtés des actions natives.

Le résultat

Un clic, et le navigateur télécharge rapport_ventes.xlsx. Ouvert dans un tableur, le fichier présente les en-têtes verts mis en forme, les colonnes redimensionnées et les montants au format monétaire. Le même principe vaut pour n'importe quel jeu de données — par exemple le chiffre d'affaires d'un point de vente restaurant.

Fichier Excel généré : en-têtes verts, lignes de commande, prix et totaux
Le classeur produit : en-têtes formatés, format monétaire et largeurs ajustées automatiquement.

⚠️ Pièges à éviter

  • Ne pas chercher un report_type="xlsx" dans le cœur : il n'existe pas en standard, c'est une brique OCA.
  • Toujours fermer le classeur avec workbook.close() avant de lire buffer.getvalue() — sinon le fichier est tronqué.
  • Importer content_disposition depuis odoo.http, et non le construire à la main : l'échappement des noms de fichiers y est géré.
  • Garder la logique de génération dans une méthode du modèle : elle devient testable par un simple TransactionCase, sans monter un serveur HTTP.

Référence des formats de cellule : documentation xlsxwriter.

À retenir

  • Le XLSX natif en Communauté = xlsxwriter dans un controller HTTP, pas de moteur de rapport dédié.
  • Une méthode modèle qui retourne des bytes sépare la génération de la diffusion — réutilisable et testable.
  • Un bouton act_url suffit à déclencher le téléchargement depuis n'importe quelle vue.

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

← Ouverture de la série Rapports Excel Article 2 — Mise en forme avancée XLSX →
Se connecter pour laisser un commentaire.
Planifier et tester l'emailing en Odoo 19 — ir.cron, déclencheurs et MockEmail
Série Tech-Email · Article 14/14 · Parcours Infrastructure