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.
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
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 1819. 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 1.
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 2.
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 10, 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ủ 3.
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 4. Đâ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ự 56. 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 7. 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) 8. 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 đó 9.
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 15. 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 20.
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 10.
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 11. 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 12.
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 13.
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 14. Cũng bản phát hành đó thêm cơ chế cắt bảng ở mốc 4 mebibyte nói trên 15.
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 17.
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 16. 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 đó.
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.jsMỗ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.
-
call_button() — trình xử lý RPC, addons/web/controllers/dataset.py:38-45 — Odoo 18.0 ↩︎
-
report_action() — odoo/addons/base/models/ir_actions_report.py:1137-1169 — Odoo 18.0 ↩︎
-
_executeReportAction() — addons/web/static/src/webclient/actions/action_service.js:1302-1352 — Odoo 18.0 ↩︎
-
downloadReport() — addons/web/static/src/webclient/actions/reports/utils.js:65-86 — Odoo 18.0 ↩︎
-
report_download() — addons/web/controllers/report.py:92-139 — Odoo 18.0 ↩︎
-
report_routes() — addons/web/controllers/report.py:23-50 — 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 — Odoo 18.0 ↩︎
-
tách header, footer và body — odoo/addons/base/models/ir_actions_report.py:368-455 — Odoo 18.0 ↩︎
-
_run_wkhtmltopdf() — odoo/addons/base/models/ir_actions_report.py:502-636 — Odoo 18.0 ↩︎
-
commit 1ced3bfca4 — pass context in kwargs in rpc calls (2019-02-15, Odoo 13.0) — Odoo ↩︎
-
commit 0573acae23 — rewrite the webclient in OWL, phase 1 (2021-05-31, Odoo 15.0) — Odoo ↩︎
-
commit 926af37343, PR #72079 — remove unused fileToken cookies (2021-06-11, Odoo 15.0) — Odoo ↩︎
-
commit bcf665a291, PR #87571 — split controllers.main in several files (2022-03-29, Odoo 16.0) — Odoo ↩︎
-
commit db4b11141d, PR #120070 — factor report downloads out of action service (2023-05-04, Odoo 17.0) — Odoo ↩︎
-
commit ad06a7b2ad, PR #131933 — improve PDF generation speed for large tables (2023-08-18, Odoo 17.0) — Odoo ↩︎
-
commit ae8658468d, PR #78857 — restrict the cookie domain in wkhtmltopdf (2024-05-27, backport 15.0-18.0) — Odoo ↩︎
-
kiểu route đổi tên từ json sang jsonrpc — addons/web/controllers/dataset.py:34 (và report.py:153) — Nhánh Odoo 19.0 ↩︎
-
URL call_button dựng bằng template literal — addons/web/static/src/webclient/actions/action_service.js:1486 — Odoo 18.0 ↩︎
-
hàm trợ giúp POST RPC chung — addons/web/static/src/core/network/rpc.js:128 — Odoo 18.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) — Nhánh Odoo 12.0 ↩︎