Se rendre au contenu

Industrialiser l'export Excel dans Odoo 19

Série Rapports Excel · Article 6/6 — Développement Odoo
25 juin 2026 par
Industrialiser l'export Excel dans Odoo 19
| Aucun commentaire pour l'instant

Série Rapports Excel · Article 6/6

Industrialiser l'export Excel dans Odoo 19

Un bouton sur un formulaire, c'est un début. Un outil d'entreprise, c'est trois portes d'entrée : un assistant de période, une action serveur déclenchable sur une sélection de la liste, et un envoi planifié par ir.cron qui dépose le rapport dans une boîte mail chaque mois. Même générateur, trois usages.

~13 minutes de lecture · niveau avancé · parcours Framework & ORM · clôture de série

Ce que tu vas apprendre

Un assistant de période

Un TransientModel avec deux dates, une vue, une action et un menu pour exporter à la demande.

Une action serveur

Un ir.actions.server lié à la vue liste, qui exporte les enregistrements sélectionnés.

Un envoi planifié

Un ir.cron mensuel qui génère la synthèse et l'envoie par e-mail en pièce jointe.

Une seule logique, trois déclencheurs

La clé d'un export industrialisé : un seul générateur de fichier, appelé depuis plusieurs points d'entrée. On factorise donc la construction du classeur et le téléchargement dans deux méthodes réutilisables, puis on branche dessus l'assistant, l'action serveur et la tâche planifiée.

1. Le générateur partagé

Le générateur agrège les lignes de self par produit avec _read_group, puis écrit une feuille de synthèse — la mise en page reprend la recette des articles précédents, condensée ici à l'essentiel.

    def _build_orders_xlsx(self, title=None):
        """Construit une synthèse XLSX du CA par produit pour `self`."""
        import xlsxwriter  # vendored par Odoo

        title = title or _('Synthèse des ventes sélectionnées')
        line_domain = [('order_id', 'in', self.ids), ('display_type', '=', False)]
        groups = self.env['sale.order.line']._read_group(
            line_domain, groupby=['product_id'], aggregates=['price_subtotal:sum', '__count'],
        )
        rows = sorted(
            ((product.display_name, count, subtotal)
             for product, subtotal, count in groups),
            key=lambda r: r[2], reverse=True,
        )
        # ... écriture du classeur (titre, en-tête, lignes, total) ...
        return data

Le second utilitaire crée une pièce jointe binaire et renvoie l'action de téléchargement standard /web/content/<id>?download=true — la façon canonique de servir un fichier généré depuis un bouton ou une action.

    def _xlsx_download_action(self, data, filename):
        """Crée une pièce jointe binaire et renvoie l'action de téléchargement."""
        attachment = self.env['ir.attachment'].create({
            'name': filename,
            'datas': base64.b64encode(data),
            'type': 'binary',
            'mimetype': MIMETYPE,
        })
        return {
            'type': 'ir.actions.act_url',
            'url': '/web/content/%s?download=true' % attachment.id,
            'target': 'self',
        }

2. L'assistant de période

Un TransientModel est un modèle éphémère, parfait pour un formulaire de saisie qui ne persiste pas. Le nôtre porte deux dates, prérenseignées sur le mois courant.

class SalesExportWizard(models.TransientModel):
    _name = 'odooskills.sales.export.wizard'
    _description = "Assistant d'export Excel des ventes par période"

    @api.model
    def _default_date_from(self):
        return fields.Date.context_today(self).replace(day=1)

    date_from = fields.Date(
        string='Du', required=True, default=_default_date_from)
    date_to = fields.Date(
        string='Au', required=True, default=fields.Date.context_today)

    def action_download(self):
        """Construit la synthèse de la période choisie et la télécharge."""
        self.ensure_one()
        if self.date_from > self.date_to:
            raise UserError(_("La date de début doit précéder la date de fin."))

        orders = self.env['sale.order'].search([
            ('state', 'in', ('sale', 'done')),
            ('date_order', '>=', fields.Datetime.to_datetime(self.date_from)),
            # date_to incluse → borne stricte au lendemain.
            ('date_order', '<', fields.Datetime.to_datetime(self.date_to + timedelta(days=1))),
        ])
        title = _('Synthèse des ventes du %s au %s') % (
            self.date_from.strftime('%d/%m/%Y'), self.date_to.strftime('%d/%m/%Y'))
        data = orders._build_orders_xlsx(title=title)
        filename = 'synthese_ventes_%s_%s.xlsx' % (
            self.date_from.strftime('%Y%m%d'), self.date_to.strftime('%Y%m%d'))
        return orders._xlsx_download_action(data, filename)

La vue, l'action et le menu donnent corps à l'assistant. Le bouton action_download est un type="object", et l'action s'ouvre en fenêtre modale (target="new").

<record id="view_sales_export_wizard_form" model="ir.ui.view">
    <field name="name">odooskills.sales.export.wizard.form</field>
    <field name="model">odooskills.sales.export.wizard</field>
    <field name="arch" type="xml">
        <form string="Exporter les ventes en Excel">
            <group>
                <field name="date_from"/>
                <field name="date_to"/>
            </group>
            <footer>
                <button name="action_download" type="object" string="Télécharger"
                        class="btn-primary"/>
                <button string="Annuler" class="btn-secondary" special="cancel"/>
            </footer>
        </form>
    </field>
</record>

<record id="action_sales_export_wizard" model="ir.actions.act_window">
    <field name="name">Synthèse Excel par période</field>
    <field name="res_model">odooskills.sales.export.wizard</field>
    <field name="view_mode">form</field>
    <field name="target">new</field>
</record>

<menuitem id="menu_sales_export_wizard" name="Synthèse Excel par période"
          parent="sale.menu_sale_report" action="action_sales_export_wizard"
          sequence="90"/>
Fenêtre modale Odoo 19 « Synthèse Excel par période » avec deux champs date Du et Au et les boutons Télécharger et Annuler
L'assistant ouvert depuis le menu Ventes : on choisit une période, puis « Télécharger ».

3. L'action serveur multi-sélection

Pour exporter une poignée de commandes cochées dans la liste, on s'appuie sur une action serveur liée. Sa méthode cible exporte simplement self — c'est-à-dire les enregistrements sélectionnés.

    def action_export_selected_xlsx(self):
        """Exporte les commandes sélectionnées dans la liste."""
        data = self._build_orders_xlsx(title=_('Synthèse — commandes sélectionnées'))
        return self._xlsx_download_action(data, 'synthese_selection.xlsx')

Le lien avec la vue liste se fait par binding_model_id et binding_view_types : l'action apparaît alors dans le menu Actions dès qu'au moins une ligne est cochée. Dans le code de l'action, records désigne la sélection.

<record id="action_export_selected_xlsx" model="ir.actions.server">
    <field name="name">Synthèse Excel (sélection)</field>
    <field name="model_id" ref="sale.model_sale_order"/>
    <field name="binding_model_id" ref="sale.model_sale_order"/>
    <field name="binding_view_types">list</field>
    <field name="state">code</field>
    <field name="code">action = records.action_export_selected_xlsx()</field>
</record>
Liste des commandes de vente Odoo 19, trois lignes cochées, menu Actions ouvert montrant l'entrée Synthèse Excel (sélection)
Trois commandes cochées : le menu « Actions » propose « Synthèse Excel (sélection) ».

4. L'envoi planifié par ir.cron

Dernière porte d'entrée : aucune. Le rapport se génère tout seul, chaque mois, et part par e-mail. La méthode calcule la période du mois écoulé, construit le fichier, crée la pièce jointe et l'envoie.

    @api.model
    def _cron_email_monthly_report(self):
        """Génère la synthèse du mois précédent et l'envoie par e-mail."""
        today = fields.Date.context_today(self)
        first_of_month = today.replace(day=1)
        last_prev = first_of_month - timedelta(days=1)
        first_prev = last_prev.replace(day=1)

        orders = self.search([
            ('state', 'in', ('sale', 'done')),
            ('date_order', '>=', fields.Datetime.to_datetime(first_prev)),
            ('date_order', '<', fields.Datetime.to_datetime(first_of_month)),
        ])
        period = first_prev.strftime('%m/%Y')
        data = orders._build_orders_xlsx(title=_('Synthèse des ventes — %s') % period)

        attachment = self.env['ir.attachment'].create({
            'name': 'synthese_ventes_%s.xlsx' % first_prev.strftime('%Y_%m'),
            'datas': base64.b64encode(data),
            'type': 'binary',
            'mimetype': MIMETYPE,
        })

        recipient = (self.env['ir.config_parameter'].sudo()
                     .get_param('odooskills.monthly_report_email')
                     or self.env.company.email)
        if not recipient:
            return False  # pas de destinataire configuré : on n'envoie rien

        mail = self.env['mail.mail'].create({
            'subject': _('Synthèse des ventes — %s') % period,
            'body_html': _('<p>Bonjour,</p><p>Veuillez trouver ci-joint la synthèse '
                           'des ventes du mois %s.</p>') % period,
            'email_to': recipient,
            'attachment_ids': [(6, 0, attachment.ids)],
        })
        mail.send()
        return True

La tâche elle-même est un enregistrement ir.cron : modèle cible, code à exécuter, périodicité mensuelle. On la livre inactive — à l'administrateur de l'activer et de renseigner le destinataire via le paramètre système odooskills.monthly_report_email.

<record id="cron_email_monthly_report" model="ir.cron">
    <field name="name">OdooSkills : synthèse mensuelle des ventes par e-mail</field>
    <field name="model_id" ref="sale.model_sale_order"/>
    <field name="state">code</field>
    <field name="code">model._cron_email_monthly_report()</field>
    <field name="user_id" ref="base.user_root"/>
    <field name="interval_number">1</field>
    <field name="interval_type">months</field>
    <field name="active" eval="False"/>
</record>
Formulaire d'action planifiée Odoo 19 montrant la tâche OdooSkills, modèle Commande client, périodicité 1 mois, code model._cron_email_monthly_report()
La tâche planifiée dans les Actions planifiées : exécution mensuelle du code de génération et d'envoi.

⚠️ Pièges à éviter

  • Un TransientModel a besoin de droits d'accès comme tout modèle : déclarer une ligne dans ir.model.access.csv.
  • Pour qu'une action serveur apparaisse sur une sélection, renseigner binding_model_id et binding_view_types (ici list) — sinon elle reste invisible.
  • Livrer un ir.cron avec active à False : on n'envoie pas d'e-mails dès l'installation, c'est à l'administrateur de l'armer.
  • Toujours prévoir le cas « pas de destinataire » : un cron qui plante chaque nuit pollue les journaux.
  • Le champ datas d'ir.attachment attend du base64 (base64.b64encode(data)), pas les octets bruts.

À retenir

  • Un générateur unique (_build_orders_xlsx + _xlsx_download_action) alimente trois déclencheurs sans duplication.
  • Assistant TransientModel, action serveur liée à la liste et ir.cron couvrent l'export à la demande, par lot et automatique.
  • Un ir.cron livré inactif, un destinataire paramétrable et une pièce jointe e-mail transforment un script en service.

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.
Un rapport Excel sur gros volumes dans Odoo 19
Série Rapports Excel · Article 5/6 — Développement Odoo