Skip to Content

Друковані форми в Odoo 19: QWeb PDF, спільний шар даних і коли потрібен Excel

Звіт у 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.

Друковані форми в Odoo 19: QWeb PDF, спільний шар даних і коли потрібен Excel
KitWorks, Volodymyr Karabanov 4 серпня 2026 р.
Поділитися цією публікацією
Теги
Архів