# Sinh báo cáo trong Odoo, phần 1: Từ cú nhấp Print tới file PDF trên đĩa

> Sáu chặng giữa cú nhấp Print trên một báo giá và file PDF rơi vào thư mục tải xuống, lần theo mã nguồn Odoo 18 và 19, kèm những khác biệt lùi về tới Odoo 12.

**Date:** 2026-09-16
**Source:** <https://trobz.com/vi/insights/odoo-report-internals/>

---



Tài liệu của chính Odoo chưa bao giờ giải thích chuyện gì diễn ra giữa lúc nhấp Print và lúc một file PDF rơi vào thư mục tải xuống, dù câu trả lời chạy qua ít nhất sáu file khác nhau ở phía client lẫn phía máy chủ.

Đây là bài đầu trong một loạt bài dự kiến về báo cáo trong Odoo. Chúng ta lần theo trọn đường đi của một request, lấy việc sinh báo giá từ đơn bán hàng làm ví dụ xuyên suốt: nhấp Print trên một báo giá, rồi xem `sale.report_saleorder` biến thành một file PDF được lưu lại. Vết chạy bên dưới được đối chiếu trực tiếp với mã nguồn Odoo 18.0, và xác nhận không đổi về bản chất trên 19.0. Người đọc đang dùng phiên bản cũ hơn sẽ có một mục riêng ở gần cuối bài, thay vì phải đọc ghi chú phiên bản ở mỗi đoạn.

## Từ cú nhấp tới máy chủ: lời gọi RPC và report action

{{< img src="/images/insights/odoo-report-internals/print-menu-pdf-quote.jpg" alt="Menu Print đang mở trên một báo giá Odoo, với mục PDF Quote được làm nổi" >}}

Nhấp Print trên một báo giá sẽ gửi đi một request được dựng như sau:

```
POST /web/dataset/call_button/${params.resModel}/${params.name}
```

URL đó không phải một route tĩnh: client dựng nó lúc chạy từ model và phương thức đang được gọi (`sale.order` và `print_quotation` trong ví dụ của chúng ta), rồi gửi qua hàm trợ giúp `rpc()` chung, hàm này luôn phát ra POST bất kể nhắm tới endpoint nào [ref](https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/static/src/webclient/actions/action_service.js#L1486)[ref](https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/static/src/core/network/rpc.js#L128). Controller phân giải phương thức của model (`sale.order.print_quotation()` trong ví dụ), chạy nó, và nếu phương thức trả về một dict mô tả một action thì dọn dẹp rồi trả dict đó về cho trình duyệt [ref](https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/controllers/dataset.py#L38-L45).

Action đó đến từ `ir.actions.report.report_action()`, phương thức nằm sau định nghĩa báo cáo (`sale.action_report_saleorder`). Nó dựng cái dict mà client sẽ hành động theo: kiểu action là `ir.actions.report`, tên kỹ thuật của báo cáo, kiểu đầu ra (`qweb-pdf` với báo giá của chúng ta), và một context mang theo `active_ids`, tức các ID bản ghi cần in [ref](https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/odoo/addons/base/models/ir_actions_report.py#L1137-L1169).

Chặng này ổn định đến mức đáng kinh ngạc. Bản thân route không đổi chỗ từ Odoo 12.0. Chữ ký của nó đổi đúng một lần, ở Odoo 13.0, khi `call_button` chuyển từ hai tham số riêng `domain_id` và `context_id` sang một dict `kwargs` duy nhất do bên gọi cung cấp [ref](https://github.com/odoo/odoo/commit/1ced3bfca457ac50845637bcd42a8e53bd7f088d), hình dạng vẫn dùng đến hôm nay.

## Client nhận lấy action

Một khi trình duyệt có dict `ir.actions.report`, action service của web client hiện tại tiếp quản. Hàm `_executeReportAction()` của nó là hậu duệ trực tiếp của một widget cũ tên `action_manager_report.js`, cái tên nay chỉ còn sống sót dưới dạng một dòng chú thích bên trong chính controller tải xuống của máy chủ [ref](https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/static/src/webclient/actions/action_service.js#L1302-L1352).

Với báo cáo PDF, hàm này trước tiên gọi `/report/check_wkhtmltopdf` để xác nhận máy chủ thật sự render được PDF, rồi gọi `downloadReport()`, hàm này POST một payload JSON nhỏ gồm URL nội bộ của báo cáo và kiểu của nó (đại loại `["/report/pdf/sale.report_saleorder/1", "qweb-pdf"]`) tới `/report/download` [ref](https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/static/src/webclient/actions/reports/utils.js#L65-L86). Đây chính là điểm mà client thời tiền OWL và client hôm nay rẽ nhánh; mục về các phiên bản cũ bên dưới nói rõ khi nào và vì sao.

## Máy chủ render, trình duyệt lưu

`report_download()` đọc payload đó, tách tên báo cáo và các ID bản ghi ra khỏi URL, rồi gọi `report_routes()`, hàm này lại gọi `_render_qweb_pdf()` để tạo ra chuỗi byte PDF thật sự [ref](https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/controllers/report.py#L92-L139)[ref](https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/controllers/report.py#L23-L50). Trước khi gửi phản hồi, nó tính ra tên file (một báo cáo có thể định nghĩa biểu thức đặt tên riêng, được tính trên chính bản ghi đang in) và đặt header biến đây thành một lượt tải xuống thay vì một lần nạp trang: `Content-Disposition: attachment`.

Phía trình duyệt, bộ xử lý tải xuống của client nhận phản hồi dưới dạng blob, đọc ngược tên file ra từ chính header `Content-Disposition` đó, rồi kích hoạt việc lưu file thật sự thông qua một phần tử link ẩn có thuộc tính `download` [ref](https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/static/src/core/network/download.js#L484-L579). Từ đây trở đi mọi thứ là hành vi chuẩn của trình duyệt: file PDF rơi vào đúng nơi trình duyệt được cấu hình để cất các file tải về.

## Bên trong wkhtmltopdf: file PDF thực sự được tạo ra thế nào

Mọi thứ tới giờ đều là định tuyến. Phần thật sự biến một template QWeb thành PDF diễn ra bên trong `_render_qweb_pdf()`, và nó đáng đi qua từng bước vì không chỗ nào trong luồng ở trên ghi lại điều đó.

Template QWeb được render thành HTML trước, y như khi hiển thị trên màn hình. HTML đó sau đó bị tách thành một header, một footer và một hoặc nhiều body (thường là mỗi bản ghi một body) [ref](https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/odoo/addons/base/models/ir_actions_report.py#L368-L455). Mỗi phần được ghi ra một file tạm riêng: một file HTML header, một file HTML footer, mỗi bản ghi một file HTML body, và một file cookie jar cho phép tiến trình render xác thực với tư cách người dùng đang yêu cầu. Odoo rồi sinh ra `wkhtmltopdf` như một tiến trình con, trỏ vào các file đó, và đọc file PDF kết quả từ đĩa khi tiến trình con kết thúc. Mọi file tạm bị xóa ngay sau đó [ref](https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/odoo/addons/base/models/ir_actions_report.py#L502-L636).

Một chi tiết đáng biết riêng: các body lớn hơn 4 mebibyte sẽ được cắt bảng thành những khối nhỏ hơn trước khi render, cốt để né một vấn đề hiệu năng nằm trong chính wkhtmltopdf. Bản vá của đội Odoo cho chuyện này dẫn ra một báo cáo 250.000 dòng từng mất khoảng một giờ để render trước khi có cơ chế cắt [ref](https://github.com/odoo/odoo/commit/ad06a7b2ad2230e8ae27b694019daf5fd73a7e77). Nếu một báo cáo có bảng rất lớn trông như bị treo, đây là chỗ đầu tiên nên nhìn vào.

## Nếu bạn đang ở Odoo 12.0 đến 17.0

Vết chạy ở trên đúng với Odoo 18.0 và 19.0. Các phiên bản trước khác ở vài điểm cụ thể, mỗi điểm gắn với một commit có thật:

**Odoo 12.0**: mốc so sánh cho cả mục này. `call_button` vẫn nhận hai tham số riêng `domain_id` và `context_id` thay vì một dict `kwargs`, `report_download` yêu cầu một tham số `token` và đặt cookie `fileToken` trên phản hồi, phần điều phối phía client chạy qua widget tiền OWL `action_manager_report.js`, và `_run_wkhtmltopdf()` xác thực bằng một tham số nội tuyến duy nhất `--cookie session_id <sid>` chứ không dùng file cookie jar tạm như mô tả ở trên [ref](https://github.com/odoo/odoo/blob/ba1ba88874e2ae9716e59ec199eb739d58c3f771/addons/web/controllers/main.py#L962-L966).

**Odoo 13.0**: chữ ký của `call_button` đổi từ hai tham số riêng `domain_id` và `context_id` sang một dict `kwargs` duy nhất như mô tả ở trên [ref](https://github.com/odoo/odoo/commit/1ced3bfca457ac50845637bcd42a8e53bd7f088d).

**Odoo 15.0**: hai thay đổi không liên quan nhau cùng đáp xuống trong một bản phát hành. Widget tiền OWL `action_manager_report.js` bị gỡ, thay bằng `_executeReportAction()` bên trong action service mới dựa trên OWL [ref](https://github.com/odoo/odoo/commit/0573acae2306bf5da2005852da9323ddc59e5431). Riêng biệt với đó, cookie `fileToken` mà client cũ từng thăm dò (một cách phát hiện lượt tải cùng origin đã xong, từ thời trình duyệt chưa có API tải blob đáng tin) bị gỡ như mã chết, vì client mới không cần tới nó nữa [ref](https://github.com/odoo/odoo/commit/926af3734384e765db070212ef12271c0b41aded).

**Odoo 16.0**: các controller của web client, trước đây dồn hết vào một file `main.py` lớn, được tách theo mối quan tâm. `call_button` chuyển vào `dataset.py`; `report_download` và `check_wkhtmltopdf` chuyển vào `report.py` [ref](https://github.com/odoo/odoo/commit/bcf665a291b86203af9f604ec5b95c8c857b20c3).

**Odoo 17.0**: phần logic tải báo cáo được rút khỏi action service thành module riêng `reports/utils.js`, ban đầu là để Point of Sale dùng lại được mã tải xuống mà không phải nạp cả action service [ref](https://github.com/odoo/odoo/commit/db4b11141d1581ebf1cd5b401801e66e77748040). Cũng bản phát hành đó thêm cơ chế cắt bảng ở mốc 4 mebibyte nói trên [ref](https://github.com/odoo/odoo/commit/ad06a7b2ad2230e8ae27b694019daf5fd73a7e77).

**Odoo 19.0**: luồng ở trên không đổi về bản chất. Khác biệt duy nhất nhìn thấy được là `call_button` và `check_wkhtmltopdf` đổi kiểu route từ `json` sang `jsonrpc`, một lần đổi tên trên toàn framework chạm tới nhiều route chứ không riêng gì báo cáo [ref](https://github.com/odoo/odoo/blob/6c8e7dcb96621dbbf634b0632f64a1be3cab7d94/addons/web/controllers/dataset.py#L34).

Một đính chính, vì đây là cái bẫy đáng biết: một lượt nghiên cứu trước đó đã đọc file cookie jar tạm trong lệnh wkhtmltopdf như thể nó được đưa vào ở Odoo 15.0, có vẻ là cùng đợt viết lại bằng OWL nói trên. Điều đó sai. Các phiên bản 12.0 đến 14.0, và ban đầu cả 15.0, dùng một cơ chế đơn giản hơn nhiều, không có file tạm nào cả: một tham số `--cookie` duy nhất. Bản dùng file tạm, có giới hạn domain và path, đến từ một bản vá bảo mật năm 2024 nhằm giới hạn cookie session được gửi tới domain nào [ref](https://github.com/odoo/odoo/commit/ae8658468dcb89bef6cba14b93db036705914630). Bản vá đó được backport về mọi nhánh còn được hỗ trợ khi ấy (15.0 đến 18.0) và bỏ qua 12.0 đến 14.0 chỉ vì các nhánh đó đã hết vòng đời. So sánh mã hiện tại của hai nhánh chỉ cho thấy một phiên bản trông ra sao hôm nay, chứ không cho biết một tính năng ra đời khi nào: một phân biệt có ý nghĩa với bất kỳ ai lặp lại kiểu nghiên cứu này trên các nhánh Odoo cũ.

## Trọn chuỗi, từ đầu tới cuối

Sáu chặng, theo thứ tự: cú nhấp nút đi tới máy chủ qua `call_button`, máy chủ dựng một dict `ir.actions.report`, action service của client điều phối nó và POST tới `/report/download`, máy chủ render template QWeb rồi đặt header tải xuống, wkhtmltopdf biến HTML đã render thành PDF thật thông qua một bộ file tạm, và trình duyệt lưu phản hồi theo tên file lấy từ header đó.

```mermaid
sequenceDiagram
    participant U as Người dùng
    participant B as Trình duyệt
    participant S as Máy chủ Odoo

    U->>B: Nhấp Print
    B->>S: POST /web/dataset/call_button
    Note over S: call_button() [1] — dataset.py — chạy sale.order.print_quotation()
    S->>S: report_action() [2] — ir_actions_report.py
    Note over S: dựng dict ir.actions.report
    S-->>B: dict ir.actions.report
    Note over B: Action service [3] — _executeReportAction()
    B->>S: POST /report/download
    Note over S: report_download() [5][6] — report.py — gọi _render_qweb_pdf()
    S->>S: tiến trình con wkhtmltopdf [8][9]
    Note over S: file tạm header/footer/body
    S-->>B: byte PDF + header Content-Disposition
    B->>U: Lưu file [7] — download.js
```

Mỗi triệu chứng đều chỉ ngược về một chặng cụ thể. Một báo cáo chẳng làm gì cả, im lặng, chỉ về hai bước đầu: hãy xem dict action thật sự chứa gì. Một file PDF tải về được nhưng render không có định dạng thì chỉ về phần tách header, footer và body bên trong `_render_qweb_pdf()`. Một báo cáo trông như treo trên tài liệu lớn thì chỉ về cơ chế cắt bảng, và về chuyện cái bảng đang xét có thật sự được cắt hay không.

Phần 2 của loạt bài này chưa chốt phạm vi. Nếu có một kiểu hỏng cụ thể đáng được lần vết sâu hơn, hãy cho chúng tôi biết và chúng tôi sẽ cân nhắc cho bài kế tiếp.

---

## References

Odoo 18.0, "call_button() — trình xử lý RPC, addons/web/controllers/dataset.py:38-45": https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/controllers/dataset.py#L38-L45
Odoo 18.0, "report_action() — odoo/addons/base/models/ir_actions_report.py:1137-1169": https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/odoo/addons/base/models/ir_actions_report.py#L1137-L1169
Odoo 18.0, "_executeReportAction() — addons/web/static/src/webclient/actions/action_service.js:1302-1352": https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/static/src/webclient/actions/action_service.js#L1302-L1352
Odoo 18.0, "downloadReport() — addons/web/static/src/webclient/actions/reports/utils.js:65-86": https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/static/src/webclient/actions/reports/utils.js#L65-L86
Odoo 18.0, "report_download() — addons/web/controllers/report.py:92-139": https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/controllers/report.py#L92-L139
Odoo 18.0, "report_routes() — addons/web/controllers/report.py:23-50": https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/controllers/report.py#L23-L50
Odoo 18.0, "tải blob và phân tích tên file phía client — addons/web/static/src/core/network/download.js:484-579": https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/static/src/core/network/download.js#L484-L579
Odoo 18.0, "tách header, footer và body — odoo/addons/base/models/ir_actions_report.py:368-455": https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/odoo/addons/base/models/ir_actions_report.py#L368-L455
Odoo 18.0, "_run_wkhtmltopdf() — odoo/addons/base/models/ir_actions_report.py:502-636": https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/odoo/addons/base/models/ir_actions_report.py#L502-L636
Odoo, "commit 1ced3bfca4 — pass context in kwargs in rpc calls (2019-02-15, Odoo 13.0)": https://github.com/odoo/odoo/commit/1ced3bfca457ac50845637bcd42a8e53bd7f088d
Odoo, "commit 0573acae23 — rewrite the webclient in OWL, phase 1 (2021-05-31, Odoo 15.0)": https://github.com/odoo/odoo/commit/0573acae2306bf5da2005852da9323ddc59e5431
Odoo, "commit 926af37343, PR #72079 — remove unused fileToken cookies (2021-06-11, Odoo 15.0)": https://github.com/odoo/odoo/commit/926af3734384e765db070212ef12271c0b41aded
Odoo, "commit bcf665a291, PR #87571 — split controllers.main in several files (2022-03-29, Odoo 16.0)": https://github.com/odoo/odoo/commit/bcf665a291b86203af9f604ec5b95c8c857b20c3
Odoo, "commit db4b11141d, PR #120070 — factor report downloads out of action service (2023-05-04, Odoo 17.0)": https://github.com/odoo/odoo/commit/db4b11141d1581ebf1cd5b401801e66e77748040
Odoo, "commit ad06a7b2ad, PR #131933 — improve PDF generation speed for large tables (2023-08-18, Odoo 17.0)": https://github.com/odoo/odoo/commit/ad06a7b2ad2230e8ae27b694019daf5fd73a7e77
Odoo, "commit ae8658468d, PR #78857 — restrict the cookie domain in wkhtmltopdf (2024-05-27, backport 15.0-18.0)": https://github.com/odoo/odoo/commit/ae8658468dcb89bef6cba14b93db036705914630
Nhánh Odoo 19.0, "kiểu route đổi tên từ json sang jsonrpc — addons/web/controllers/dataset.py:34 (và report.py:153)": https://github.com/odoo/odoo/blob/6c8e7dcb96621dbbf634b0632f64a1be3cab7d94/addons/web/controllers/dataset.py#L34
Odoo 18.0, "URL call_button dựng bằng template literal — addons/web/static/src/webclient/actions/action_service.js:1486": https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/static/src/webclient/actions/action_service.js#L1486
Odoo 18.0, "hàm trợ giúp POST RPC chung — addons/web/static/src/core/network/rpc.js:128": https://github.com/odoo/odoo/blob/1c0e6814276c47790308236f9a3ea1fa30102060/addons/web/static/src/core/network/rpc.js#L128
Nhánh Odoo 12.0, "mốc trước 13.0 cho call_button, cookie fileToken và tham số --cookie nội tuyến của wkhtmltopdf — addons/web/controllers/main.py:962-966,1658-1712 (và ir_actions_report.py:389-460)": https://github.com/odoo/odoo/blob/ba1ba88874e2ae9716e59ec199eb739d58c3f771/addons/web/controllers/main.py#L962-L966

