Ce que tu vas apprendre
Agréger en Python
Construire les séries à tracer — CA par produit, CA par mois — en sommant directement sur le recordset, sans SQL.
Trois types de graphiques
Histogramme, camembert et courbe avec add_chart, chacun nourri
d'une plage de données par add_series.
Un tableau de bord
Poser les graphiques sur une feuille dédiée avec insert_chart —
des objets vivants, pas des images figées.
bytes
+ controller HTTP + bouton d'en-tête). Une instance Odoo 19 Communauté, le module
sale, aucune dépendance externe — xlsxwriter gère nativement les
graphiques.
Un graphique Excel n'est pas une image
Point clé avant de coder : xlsxwriter ne colle pas une image dans la feuille,
il écrit un vrai graphique Excel. Il pointe vers une plage de cellules,
se met à jour si les données changent, et reste éditable dans le tableur. Le principe tient
en trois temps : écrire les données dans une feuille, créer un objet graphique avec
workbook.add_chart, puis l'ancrer sur une feuille avec
worksheet.insert_chart.
Tout passe par une méthode _build_sale_lines_xlsx_chart sur
sale.order, appuyée sur un agrégateur et une petite méthode d'écriture de
table. On construit bloc par bloc.
1. Agréger le chiffre d'affaires
Un graphique a besoin de séries courtes et propres, pas de la liste brute des lignes. On agrège donc le CA HT par produit et par mois dans deux dictionnaires, puis on trie : les produits par montant décroissant (top 10), les mois par ordre chronologique.
def _aggregate_ca(self):
"""Agrège le CA HT des lignes par produit et par mois."""
by_product = {}
by_month = {}
for order in self:
month_key = order.date_order.strftime('%Y-%m') if order.date_order else _('Inconnu')
for line in order.order_line.filtered(lambda l: not l.display_type):
name = line.product_id.display_name or line.name
by_product[name] = by_product.get(name, 0.0) + line.price_subtotal
by_month[month_key] = by_month.get(month_key, 0.0) + line.price_subtotal
products = sorted(by_product.items(), key=lambda kv: kv[1], reverse=True)[:10]
months = sorted(by_month.items(), key=lambda kv: kv[0])
# Libellé lisible 'MM/AAAA' pour les mois datés.
months = [
(('%s/%s' % (k[5:], k[:4])) if '-' in k else k, v)
for k, v in months
]
return products, months
Trier les mois sur la clé 'AAAA-MM' garantit l'ordre chronologique avant de
transformer la clé en libellé 'MM/AAAA' plus lisible. Cette agrégation Python
suffit pour des volumes raisonnables ; un prochain article de la série montrera comment
agréger en SQL pour les gros jeux de données.
2. Les feuilles de données et le tableau de bord
Comme à l'article multi-feuilles, on crée la feuille « Tableau de bord » en premier pour qu'elle reste l'onglet de tête, puis deux feuilles de données. Les graphiques référenceront ces feuilles par leur nom — l'ordre de création des feuilles de données n'a aucune importance, les références sont résolues à la fermeture du classeur.
# Feuille tableau de bord créée EN PREMIER → onglet en tête. Les
# graphiques référencent des plages par leur nom de feuille ; les
# feuilles de données peuvent être créées après.
dashboard = workbook.add_worksheet(_('Tableau de bord'))
dashboard.activate()
prod_sheet_name = _('CA par produit')
month_sheet_name = _('CA par mois')
n_prod = self._write_data_sheet(
workbook.add_worksheet(prod_sheet_name), prod_sheet_name,
_('Produit'), products, title_fmt, header_fmt, text_fmt, money_fmt,
)
n_month = self._write_data_sheet(
workbook.add_worksheet(month_sheet_name), month_sheet_name,
_('Mois'), months, title_fmt, header_fmt, text_fmt, money_fmt,
)
La méthode d'écriture est volontairement générique : un titre fusionné, un en-tête, puis les couples (libellé, montant). Les données démarrent en ligne 3 (index 2) — un détail qui fixera les coordonnées des plages de graphiques.
def _write_data_sheet(self, sheet, sheet_title, label, rows,
title_fmt, header_fmt, text_fmt, money_fmt):
"""Écrit une table à deux colonnes (libellé, CA HT). Renvoie le nb de lignes."""
sheet.merge_range(0, 0, 0, 1, sheet_title, title_fmt)
sheet.set_row(0, 22)
sheet.write(1, 0, label, header_fmt)
sheet.write(1, 1, _('CA HT'), header_fmt)
row = 2
for name, amount in rows:
sheet.write(row, 0, name, text_fmt)
sheet.write_number(row, 1, amount, money_fmt)
row += 1
sheet.set_column(0, 0, 32)
sheet.set_column(1, 1, 16)
return len(rows)
3. L'histogramme du CA par produit
Un graphique se crée avec add_chart({'type': ...}), reçoit une série via
add_series, puis se pose avec insert_chart. Le plus robuste pour
décrire une plage est la forme liste
[nom_feuille, première_ligne, première_colonne, dernière_ligne, dernière_colonne] :
pas de chaîne A1 à construire à la main, donc pas d'erreur d'index.
# --- Graphique 1 : histogramme du CA par produit ---
if n_prod:
col_chart = workbook.add_chart({'type': 'column'})
col_chart.add_series({
'name': _('CA HT par produit'),
'categories': [prod_sheet_name, 2, 0, n_prod + 1, 0],
'values': [prod_sheet_name, 2, 1, n_prod + 1, 1],
'data_labels': {'value': True, 'num_format': '#,##0\\ €'},
'fill': {'color': '#1D6F42'},
})
col_chart.set_title({'name': _('CA HT par produit')})
col_chart.set_legend({'none': True})
col_chart.set_y_axis({'num_format': '#,##0\\ €'})
col_chart.set_size({'width': 640, 'height': 380})
dashboard.insert_chart('B2', col_chart)
Les données commençant en ligne 3 (index 2) et la feuille comptant n_prod
lignes, la plage va de l'index 2 à n_prod + 1. On masque la
légende (une seule série), on formate l'axe et les étiquettes en euros, et on ancre le
graphique en B2.
4. Le camembert de répartition
Le camembert réutilise exactement la même plage de données — seul le type
change. Ses étiquettes affichent un pourcentage plutôt qu'un montant, et la légende passe
en bas pour laisser de la place aux noms de produits.
# --- Graphique 2 : camembert de répartition par produit ---
pie_chart = workbook.add_chart({'type': 'pie'})
pie_chart.add_series({
'name': _('Répartition du CA par produit'),
'categories': [prod_sheet_name, 2, 0, n_prod + 1, 0],
'values': [prod_sheet_name, 2, 1, n_prod + 1, 1],
'data_labels': {'percentage': True},
})
pie_chart.set_title({'name': _('Répartition du CA par produit')})
pie_chart.set_legend({'position': 'bottom'})
pie_chart.set_size({'width': 640, 'height': 420})
dashboard.insert_chart('B22', pie_chart)
Le data_labels à percentage est calculé par Excel à l'ouverture :
inutile de pré-calculer les parts. La légende en bottom évite que les longs
libellés de produits débordent à droite du graphique.
5. La courbe du CA par mois
La courbe trace l'évolution temporelle : elle pointe vers la seconde feuille de données (CA par mois). Une largeur de trait et des marqueurs ronds rendent la tendance lisible.
# --- Graphique 3 : courbe du CA par mois ---
if n_month:
line_chart = workbook.add_chart({'type': 'line'})
line_chart.add_series({
'name': _('CA HT par mois'),
'categories': [month_sheet_name, 2, 0, n_month + 1, 0],
'values': [month_sheet_name, 2, 1, n_month + 1, 1],
'line': {'color': '#1D6F42', 'width': 2.25},
'marker': {'type': 'circle', 'size': 6},
})
line_chart.set_title({'name': _('CA HT par mois')})
line_chart.set_legend({'none': True})
line_chart.set_y_axis({'num_format': '#,##0\\ €'})
line_chart.set_size({'width': 640, 'height': 380})
dashboard.insert_chart('K2', line_chart)
Trois graphiques sur la même feuille, ancrés en B2, B22 et
K2 : un véritable tableau de bord. Chacun reste un objet Excel vivant, que
l'utilisateur peut retailler, recolorer ou repointer.
Le controller et le bouton
Le service est identique au reste de la série : une route HTTP réservée aux utilisateurs
connectés sert les octets, déclenchée par un bouton d'en-tête sur sale.order.
@route('/odooskills/export/sale_lines/xlsx_chart', type='http', auth='user', readonly=True)
def export_sale_lines_xlsx_chart(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_chart()
headers = [
('Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),
('Content-Disposition', content_disposition('rapport_ventes_graphiques.xlsx')),
]
return request.make_response(content, headers)
⚠️ Pièges à éviter
- Un graphique pointe vers des cellules : la feuille de données doit exister et porter les valeurs. Sans données écrites, le graphique s'affiche vide.
- Préférer la forme liste
[feuille, l1, c1, l2, c2]à la chaîneA1pour les plages : moins d'erreurs d'index, et le nom de feuille est géré pour toi. - Attention aux bornes : données démarrant en index
2surnlignes → dernière ligne à l'indexn + 1. - Le format monétaire d'un axe ou d'une étiquette s'écrit avec un antislash d'échappement,
#,##0\\ €, comme pour une cellule. - Sur un camembert aux libellés longs, placer la légende en
bottomévite qu'elle déborde à droite.
À retenir
- Agréger d'abord en Python des séries courtes ; un graphique se nourrit d'une plage, pas d'une liste brute de lignes.
add_chart+add_series+insert_chart: trois appels suffisent, et letypeseul distingue histogramme, camembert et courbe.- Les graphiques sont des objets Excel vivants, pointés sur des cellules — éditables et recalculés à l'ouverture, pas des images figées.
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 multi-feuilles avec sommaire — l'article précédent, dont on réutilise la structure de classeur.
- Générer un rapport Excel natif dans Odoo 19 — le point de départ de la série.
- Écrire des tests automatisés en Odoo 19 — pour vérifier qu'un classeur embarque bien ses graphiques.