Звіт у Odoo — це запис ir.actions.report, який каже системі, який QWeb-шаблон рендерити і в що. У 19-й версії варіантів рівно три:
report_type = fields.Selection([
('qweb-html', 'HTML'),
('qweb-pdf', 'PDF'),
('qweb-text', 'Text'),
], required=True, default='qweb-pdf', ...)
Excel серед них немає. Тримаємо це в голові — до нього ще дійдемо.
Мінімальний робочий звіт — дія плюс шаблон плюс, за потреби, окремий report.paperformat. Ось наш production-приклад з модуля kw_invoice_rahf (Рахунок-фактура):
<record id="kw_invoice_rahf_paperformat_a4" model="report.paperformat">
<field name="name">Rahunok Faktura Invoice A4</field>
<field name="format">A4</field>
<field name="orientation">Portrait</field>
<field name="margin_top">7</field>
<field name="margin_bottom">10</field>
<field name="margin_left">7</field>
<field name="margin_right">7</field>
<field name="dpi">90</field>
</record>
<record id="kw_invoice_rahf_account_move_report" model="ir.actions.report">
<field name="name">Rahunok Faktura Invoice</field>
<field name="model">account.move</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">kw_invoice_rahf.kw_invoice_rahf_container</field>
<field name="binding_model_id" ref="account.model_account_move"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="kw_invoice_rahf.kw_invoice_rahf_paperformat_a4"/>
</record>
binding_type=report разом з binding_model_id — це те, що додає пункт у меню "Друк" на формі документа. Без них дію можна викликати тільки програмно.
Як влаштований сам шаблон
Контейнер циклом проходить по документах і викликає шаблон форми:
<template id="kw_invoice_rahf_container">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="kw_invoice_rahf.kw_invoice_rahf_template" t-lang="doc.partner_id.lang"/>
</t>
</t>
</template>
t-lang тут не вигадана атрибуція — це офіційний аліас t-options-lang, і QWeb дозволяє ставити його лише на той самий вузол, де стоїть t-call, інакше падає SyntaxError ще на етапі компіляції шаблону. Кожен документ друкується мовою свого контрагента, навіть якщо в пачці на друк вони різномовні.
Сам шаблон форми викликає web.basic_layout, а не звичний web.external_layout:
<template id="kw_invoice_rahf_template">
<t t-call="web.basic_layout">
...
</t>
</template>
Важливо! web.basic_layout сам усередині ще раз викликає web.html_container — у дереві виклику він з'являється двічі: раз як зовнішній контейнер, і ще раз на кожен документ у циклі. Це не помилка і не подвоєння сторінки. Odoo не рендерить дерево HTML буквально в PDF — метод _prepare_html розбирає вже готовий HTML і ріже його по div.article (той самий div, який basic_layout вішає з атрибутами data-oe-model/data-oe-id/data-oe-lang), а не по вкладених <html>. external_layout цей div теж додає, тільки малює над ним ще й шапку компанії з логотипом — для бухгалтерських бланків вона зайва, бо бланк сам малює таблицю "Постачальник / Одержувач".
Спільний шар під кількома формами
Рахунок-фактура, акт виконаних робіт і видаткова накладна — три окремі модулі (kw_invoice_rahf, kw_invoice_akt, kw_invoice_vydn), але жоден з них не рахує суму прописом чи назву валюти сам. Це винесено в kw_invoice_doc_base:
class AccountMove(models.Model):
_inherit = 'account.move'
kw_responsible_person = fields.Many2one('res.partner', ...)
kw_amount_ukr_text = fields.Char(compute='_compute_kw_amount_ukr_text')
kw_currency_name = fields.Char(compute='_compute_kw_currency_name')
kw_discount_sum = fields.Float(compute='_compute_kw_discount_sum')
def _compute_kw_amount_ukr_text(self):
for obj in self:
obj.kw_amount_ukr_text = '{} {} {:0>2} {}'.format(
num2words(int(obj.amount_total), lang='uk'),
obj.kw_currency_name,
round(100 * (obj.amount_total - int(obj.amount_total))),
obj.kw_currency_cent_name,
).capitalize()
І окреме поле в product.template, яке вирішує, чи товар взагалі потрапляє в друковану форму — трапляються товарні рядки, що існують тільки для внутрішнього обліку:
kw_is_added_to_doc = fields.Boolean(
string="Is product added to invoices", default=True)
І рахунок-фактура, і акт перевіряють line.kw_is_added_to_doc в t-foreach по рядках таблиці. Дедублікація тут доведена до кінця: у kw_invoice_rahf, kw_invoice_akt і kw_invoice_vydn взагалі немає власного каталогу models/ — вся розрахункова логіка й саме поле kw_is_added_to_doc живуть тільки в kw_invoice_doc_base, три інші модулі приносять лише шаблон і paperformat. Коли клієнт просить приховати рядок знижки чи додати новий тип бланка — правиться одне місце, а не три шаблони окремо. Ну ок, для будь-якого спільного коду це очевидна річ, але саме на друкованих формах її найчастіше ігнорують і копіюють весь шаблон цілком, разом з майбутніми багами.
Пастка: paperformat під тип документа, не під замовчання
Та сама Рахунок-фактура — портрет з полями 7 мм. Товарно-транспортна накладна (kw_stock_ttn) — ландшафт з полями 15 мм, бо в неї на порядок ширша таблиця з даними перевізника:
<record id="kw_stock_ttn_paperformat_a4" model="report.paperformat">
<field name="format">A4</field>
<field name="orientation">Landscape</field>
<field name="margin_top">15</field>
<field name="margin_left">15</field>
<field name="margin_right">15</field>
</record>
Неправильно — лишити paperformat за замовчанням і підганяти широку таблицю під портретний А4 стилями (зменшений шрифт, overflow, що на PDF просто не працює). Правильно — завести окремий report.paperformat під кожен тип бланка і прив'язати через paperformat_id: це декларативно, видно списком у налаштуваннях компанії, і не ламається при апдейті модуля.
Пастка: сума і кома
У наших бланках сума виводиться не через t-field з widget="monetary", а вручну:
<span t-esc="'{:10.2f}'.format(doc.amount_total).replace('.',',')"/>
Причина — widget="monetary" форматує число за мовними налаштуваннями контексту рендерингу, а не за фіксованим українським стандартом бухгалтерського документа. Якщо контрагент — іноземна компанія з англійською локаллю, стандартний віджет мовчки поставить крапку замість коми, і бланк виглядатиме неправильно саме там, де точність критична — в сумі до сплати.
Пастка: великі таблиці і продуктивність
Це вже не наша знахідка, а те, що зашито в саме ядро Odoo, і про це варто знати, якщо звіт друкує документ на тисячі рядків:
# HACK: wkhtmltopdf doesn't like big table at all and the
# processing time become exponential with the number
# of rows (like 1H for 250k rows).
if len(body) < 4 * 1024 * 1024: # 4Mib
body_file.write(body.encode())
else:
tree = lxml.html.fromstring(body)
_split_table(tree, 500)
body_file.write(lxml.html.tostring(tree))
Важливо! Якщо тіло HTML-документа перевищує 4 МіБ, Odoo сам ріже таблицю на шматки по 500 рядків — інакше wkhtmltopdf справді йде в експоненту за часом обробки. Для наших бланків з десятками рядків це не актуально, але для оборотно-сальдових чи товарних звітів на тисячі позицій — саме тут проходить межа, за якою краще пагінувати дані заздалегідь, ніж чекати, поки це зробить за вас Odoo.
Коли PDF не підходить
Повертаємось до порожнього місця в report_type — Excel там немає взагалі. Коли клієнту потрібен не PDF для друку, а робочий файл з формулами, який можна далі рахувати, ir.actions.report для цього не призначений. У kw_refreshable_report ми не намагались це обійти всередині моделі звітів, а завели окрему модель і свій HTTP-контролер:
@http.route(['/kw_refreshable_report/<code>.xlsx'], type='http',
methods=['GET'], csrf=False, website=True, auth='user')
def kw_refreshable_report_xls(self, code, **kw):
report = http.request.env['kw.refreshable.report'].sudo().search(
[('code', '=', code)], limit=1)
...
return http.send_file(
file_path, mimetype='application/octet-stream; charset=binary',
filename=f'{report.name}.xlsx', as_attachment=True, cache_timeout=5)
Файл генерується заздалегідь окремим методом моделі — openpyxl.Workbook(), довільні поля через safe_eval по кожному запису, формат клітинки береться з налаштувань лінії звіту:
db_model = http.request.env[report_id.db_model]
for obj in db_model.sudo().search(criteria):
for f in field_list:
val = safe_eval.safe_eval(f[1], {'obj': obj, 'env': http.request.env})
sheet.cell(row=j, column=i, value=val)
Ніякого ir.actions.report тут немає в принципі — це звичайна модель з кнопкою "Generate" (викликає refreshable_report_prepare_xls) і кнопкою "Download" (веде на контролер вище). «Refreshable» в назві — не фігура мови: є ще один публічний ендпоінт, /kw_refreshable_report/<code>.html, який віддає ті самі дані як HTML-таблицю. Захист токеном тут не безумовний: він спрацьовує лише якщо на записі kw.refreshable.report увімкнено is_external_access_allowed=True (сам зовнішній доступ) і is_token_protected=True (перевірка токена) — обидва поля вимкнені за замовчанням. Якщо звіт відкрито назовні без токен-захисту, посилання працює для будь-кого, у кого воно є, — без додаткової перевірки на цьому шляху. У xlsm-шаблон (окрема модель kw.refreshable.report.template, завантажений ir.attachment з макросом веб-запиту) можна вбудувати посилання на цей ендпоінт — і файл буде оновлюватися прямо в Excel, без повторного звернення до Odoo за новим PDF чи навіть новим xlsx.
Коли токен-захист увімкнено, він одноразовий: після успішної перевірки token.refresh_token() одразу генерує новий рядок, і стара копія посилання, що потрапила не в ті руки, перестає працювати з наступного запиту.
Тема за матеріалом https://www.cybrosys.com/blog/everything-you-need-to-know-about-odoo-19-reports-with-examples. Код і поведінку перевірено на Odoo 19.0.