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.
bytes + controller HTTP), mais une méthode de génération beaucoup plus soignée.
Une instance Odoo 19 Communauté, le module sale, aucune dépendance externe.
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 :
| Objectif | num_format | Rendu |
|---|---|---|
| Monétaire euro | #,##0.00\ € | 1 280,00 € |
| Pourcentage littéral | 0.0"%" | 10,0% |
| Date courte | dd/mm/yyyy | 25/06/2026 |
| Entier séparé | #,##0 | 12 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.
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.
⚠️ Pièges à éviter
- Une formule écrite sans
value=s'affiche à0tant 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éral0.0"%". - Écrire une date avec
writela transforme en texte. Utiliserwrite_datetimeavec un objetdatepour 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_formatidentique dans la boucle gonfle le fichier : déclarer la palette une fois, en dehors.
À retenir
- Un
add_formatest un objet réutilisable ; la propriéténum_formatporte tout le formatage monétaire, pourcentage et date. merge_range,freeze_panesetset_columndonnent la structure ;set_landscape/fit_to_pagespréparent l'impression.conditional_formatajoute des signaux visuels, etwrite_formulaavecvalue=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📘 Pour aller plus loin : nos formations Odoo 19
À lire également
- Générer un rapport Excel natif dans Odoo 19 — l'article fondateur de la série, à lire d'abord.
- Écrire des tests automatisés en Odoo 19 — pour verrouiller la mise en forme par un test qui inspecte le binaire produit.
- Côté métier, voir comment alimenter ce rapport : configurer la gestion de restaurant et exporter son chiffre d'affaires mis en forme.