Ce que tu vas apprendre
Plusieurs feuilles
Regrouper les enregistrements en Python, puis créer une feuille
add_worksheet par groupe avec un nom d'onglet valide.
Une feuille sommaire
Bâtir une feuille d'accueil qui récapitule chaque groupe et son total, créée en premier pour rester en tête des onglets.
Des liens internes
Relier le sommaire aux feuilles de détail avec write_url et la
cible internal:, comme un vrai tableau de bord cliquable.
bytes + controller HTTP).
Une instance Odoo 19 Communauté, le module sale, aucune dépendance externe.
D'une feuille à un classeur structuré
Dès qu'un rapport doit séparer les données par dimension — par commercial, par société,
par mois — entasser le tout sur une feuille unique devient illisible. Un classeur Excel
accepte autant de feuilles que nécessaire : workbook.add_worksheet(nom) en
crée une et renvoie l'objet feuille. La difficulté n'est pas technique, elle est
d'organisation : nommer correctement les onglets, calculer un total par
feuille, et offrir une porte d'entrée unique vers l'ensemble.
Tout tient dans une méthode _build_sale_lines_xlsx_sheets posée sur
sale.order, secondée par deux méthodes privées qui remplissent
respectivement une feuille de détail et la feuille sommaire. On la construit bloc par bloc.
1. Regrouper les commandes par commercial
Avant de dessiner quoi que ce soit, on répartit le recordset en groupes. Un simple
dictionnaire dont les valeurs sont des recordsets sale.order suffit : l'union
|= accumule les commandes du même commercial. On trie ensuite par nom pour un
ordre d'onglets stable.
# --- Regroupement des commandes par commercial ---
groups = {}
for order in self:
commercial = order.user_id.name or _('Sans commercial')
groups.setdefault(commercial, self.env['sale.order'])
groups[commercial] |= order
# Ordre stable : alphabétique sur le nom du commercial.
ordered = sorted(groups.items(), key=lambda kv: kv[0].lower())
Travailler avec des recordsets plutôt que des listes d'id garde tout l'ORM à
portée de main dans chaque feuille : on réutilisera order.partner_id ou
line.price_subtotal sans relire la base.
2. Le sommaire d'abord, puis les feuilles de détail
Détail d'ergonomie qui compte : dans xlsxwriter, l'ordre des onglets suit
l'ordre de création des feuilles. Pour que le sommaire reste en tête, on
le crée en premier — mais on le remplit en dernier, une fois les totaux par feuille connus.
On crée donc la feuille sommaire vide, on l'active, puis on génère les feuilles de détail.
# --- Feuille sommaire créée EN PREMIER : l'ordre de création fixe ---
# l'ordre des onglets dans xlsxwriter, donc le sommaire reste en tête.
# On la remplit après coup, une fois les totaux par feuille connus.
summary = workbook.add_worksheet(_('Sommaire'))
summary.activate()
# --- Une feuille de détail par commercial ---
used_names = {_('sommaire')} # réserve le nom de la feuille d'accueil
summary_rows = [] # (sheet_name, nb_commandes, total_ht)
for commercial, orders in ordered:
sheet_name = self._safe_sheet_name(commercial, used_names)
sheet = workbook.add_worksheet(sheet_name)
total = self._fill_detail_sheet(
sheet, commercial, orders, headers,
title_fmt, header_fmt, text_fmt, date_fmt, money_fmt,
total_lbl_fmt, total_money_fmt,
)
summary_rows.append((sheet_name, len(orders), total))
Un nom de feuille Excel obéit à des règles strictes : 31 caractères maximum, sans les
caractères [ ] : * ? / \, et unique dans le classeur. Le nom d'un commercial
peut violer chacune de ces règles. On le passe donc par un nettoyeur dédié, qui réserve
aussi le nom « Sommaire » pour éviter toute collision.
@staticmethod
def _safe_sheet_name(name, used):
"""Nettoie un nom de feuille Excel : 31 car. max, sans []:*?/\\ et unique."""
clean = re.sub(r'[\[\]:*?/\\]', ' ', name or _('Sans commercial')).strip()
clean = (clean or _('Sans commercial'))[:31]
candidate, n = clean, 1
while candidate.lower() in used:
n += 1
suffix = ' (%d)' % n
candidate = clean[:31 - len(suffix)] + suffix
used.add(candidate.lower())
return candidate
La troncature tient compte du suffixe : si deux commerciaux portent un nom identique après
nettoyage, le second devient … (2) sans jamais dépasser 31 caractères.
3. Remplir une feuille de détail
Chaque feuille de détail reprend la recette des articles précédents : un titre fusionné, une ligne d'en-tête, des données écrites avec le bon type, et une ligne de total par formule. La méthode renvoie le total HT du groupe, qui alimentera le sommaire.
def _fill_detail_sheet(self, sheet, commercial, orders, headers, ...):
"""Remplit une feuille de détail pour un commercial. Renvoie le total HT."""
last_col = len(headers) - 1
sheet.merge_range(
0, 0, 0, last_col,
_('Commandes — %s') % commercial, title_fmt,
)
sheet.set_row(0, 24)
for col, label in enumerate(headers):
sheet.write(1, col, label, header_fmt)
widths = [len(h) for h in headers]
row = 2
total_ht = 0.0
for order in orders:
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_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)
for col, value in enumerate(values):
widths[col] = max(widths[col], len(str(value or '')))
row += 1
La ligne de total ferme la feuille avec une formule =SUM(...) dont on passe la
valeur en cache via value= — sans quoi le tableur afficherait 0
avant recalcul, un piège déjà rencontré à l'article précédent.
first_data_row, last_data_row = 2, row - 1
if last_data_row >= first_data_row:
sheet.write(row, 0, _('Total'), total_lbl_fmt)
for col in range(1, last_col):
sheet.write_blank(row, col, None, total_lbl_fmt)
sheet.write_formula(
row, last_col,
'=SUM(F%d:F%d)' % (first_data_row + 1, last_data_row + 1),
total_money_fmt, value=total_ht,
)
for col, width in enumerate(widths):
sheet.set_column(col, col, min(width + 2, 50))
sheet.freeze_panes(2, 0)
# Impression : paysage, largeur ajustée, titre + en-tête répétés.
sheet.set_landscape()
sheet.fit_to_pages(1, 0)
sheet.repeat_rows(0, 1)
return total_ht
4. La feuille sommaire et ses liens internes
Le sommaire est le cœur de l'article. Pour chaque groupe, il affiche le nom du commercial,
son nombre de commandes et son total — le nom étant un lien cliquable vers
sa feuille. C'est write_url avec une cible internal: qui crée ce
lien interne au classeur ; sa syntaxe reprend la notation Excel
'Nom feuille'!A1, apostrophes internes doublées comprises.
Un seul format s'ajoute à la palette héritée des articles précédents : le style de lien, vert et souligné.
link_fmt = workbook.add_format({'font_color': '#1D6F42', 'underline': 1, 'border': 1})
def _fill_summary_sheet(self, sheet, summary_rows, ...):
"""Feuille d'accueil : une ligne par feuille de détail, avec lien interne."""
cols = [_('Commercial'), _('Commandes'), _('Total HT')]
sheet.merge_range(0, 0, 0, len(cols) - 1, _('Sommaire par commercial'), title_fmt)
sheet.set_row(0, 24)
for col, label in enumerate(cols):
sheet.write(1, col, label, header_fmt)
row = 2
grand_total = 0.0
for sheet_name, nb_orders, total in summary_rows:
grand_total += total
# Lien interne : `internal:'Nom feuille'!A1`. Les apostrophes
# internes au nom sont doublées, comme dans une formule Excel.
target = "internal:'%s'!A1" % sheet_name.replace("'", "''")
sheet.write_url(row, 0, target, link_fmt, sheet_name)
sheet.write_number(row, 1, nb_orders, text_fmt)
sheet.write_number(row, 2, total, money_fmt)
row += 1
if summary_rows:
sheet.write(row, 0, _('Total général'), total_lbl_fmt)
sheet.write_blank(row, 1, None, total_lbl_fmt)
sheet.write_number(row, 2, grand_total, total_money_fmt)
sheet.set_column(0, 0, 28)
sheet.set_column(1, 1, 12)
sheet.set_column(2, 2, 16)
sheet.freeze_panes(2, 0)
Le quatrième argument de write_url est le texte affiché : on y remet le nom de
feuille, pour que la cellule lise « Marc Demo » et non l'adresse technique. La dernière
ligne agrège un total général en sommant les totaux déjà calculés par
feuille — pas besoin de relire toutes les lignes.
5. Le controller et le bouton
Le reste est identique aux articles précédents : un controller HTTP réservé aux utilisateurs
connectés sert les octets du classeur, et un bouton d'en-tête sur sale.order
redirige vers cette route via une action ir.actions.act_url.
@route('/odooskills/export/sale_lines/xlsx_sheets', type='http', auth='user', readonly=True)
def export_sale_lines_xlsx_sheets(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_sheets()
headers = [
('Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),
('Content-Disposition', content_disposition('rapport_ventes_multi_feuilles.xlsx')),
]
return request.make_response(content, headers)
Sélectionne plusieurs commandes dans la liste, ou ouvre-en une, et le bouton produit un classeur dont les onglets reflètent les commerciaux présents.
⚠️ Pièges à éviter
- L'ordre des onglets suit l'ordre de création des feuilles : pour garder le sommaire en tête, le créer en premier et le remplir ensuite.
- Un nom de feuille dépassant 31 caractères ou contenant
[ ] : * ? / \fait échouer la génération : nettoyer systématiquement. - Deux feuilles ne peuvent pas porter le même nom : prévoir un suffixe d'unicité.
- Pour un lien interne, c'est
write_url(..., "internal:'Feuille'!A1")— le préfixeinternal:est obligatoire, et les apostrophes du nom se doublent comme dans une formule. - Calculer le total général en resommant les totaux par feuille évite de reparcourir toutes les lignes une seconde fois.
À retenir
- Regrouper le recordset en Python, puis une feuille
add_worksheetpar groupe : un classeur multi-feuilles n'est qu'une boucle bien ordonnée. - Créer la feuille sommaire en premier la maintient en tête des onglets ; la remplir en dernier donne accès aux totaux déjà calculés.
write_urlavec une cibleinternal:transforme le sommaire en tableau de bord cliquable vers chaque feuille de détail.
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
- Mettre en forme un rapport Excel avancé — l'article dont on réutilise ici la palette de formats.
- 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 porte bien autant de feuilles que de groupes.