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.
_read_group vue à l'article
précédent. Une instance Odoo 19 Communauté, les modules sale et
mail. Notions utiles : TransientModel, actions serveur, tâches
planifiées.
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"/>
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>
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>
⚠️ Pièges à éviter
- Un
TransientModela besoin de droits d'accès comme tout modèle : déclarer une ligne dansir.model.access.csv. - Pour qu'une action serveur apparaisse sur une sélection, renseigner
binding_model_idetbinding_view_types(icilist) — sinon elle reste invisible. - Livrer un
ir.cronavecactiveà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
datasd'ir.attachmentattend 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 etir.croncouvrent l'export à la demande, par lot et automatique. - Un
ir.cronlivré 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📘 Pour aller plus loin : nos formations Odoo 19
À lire également
- Un rapport Excel sur gros volumes — l'article précédent, dont on réutilise l'agrégation SQL.
- Générer un rapport Excel natif dans Odoo 19 — revenir au tout début de la série.
- Écrire des tests automatisés en Odoo 19 — pour couvrir assistant, action serveur et cron par des tests.