Nút "In" mặc định của Odoo cứ thế xuất PDF, không hỏi gì. Nhưng nhiều lúc bạn cần hỏi trước: in thẻ hạng nào, có kèm lịch sử không, khoảng ngày nào. Chỗ để hỏi là một wizard — hộp thoại thu tham số, rồi từ đó gọi report với đúng những gì người dùng chọn. Bài này dựng một wizard "In thẻ theo hạng" hoàn chỉnh, và vạch ra một điểm dễ sai: tham số bạn truyền vào không xuất hiện trong QWeb dưới cái tên bạn tưởng.

Wizard gọi report thế nào

Wizard là một TransientModel (dữ liệu tạm, tự dọn). Nó có các field để người dùng nhập, và một nút gọi report_action(records, data=...) — hàm này trả về đúng cái action mà nút "In" vẫn dùng, nhưng kèm theo records bạn tự chọn và một data tuỳ ý:

Ảnh chụp đoạn mã nền tối hai phần, phần trên là class InTheWizard kế thừa models TransientModel tên quan.in.the.wizard với field hang_the Selection required và kem_lich_su Boolean mặc định True, method action_in tìm các thẻ theo hang_the nếu rỗng thì raise UserError còn có thì return self env ref action_report_the report_action truyền the_ids và data gồm kem_lich_su; phần dưới là report với t-set kem_ls t-value kem_lich_su nếu khác None ngược lại True và div t-if kem_ls bọc phần h3 Lịch sử tích điểm

Hình 1: Wizard quan.in.the.wizard thu hai tham số — hạng thẻ và có kèm lịch sử không. action_in lọc đúng các thẻ theo hạng rồi gọi report_action(the_ids, data={'kem_lich_su': ...}). Chú ý phần dưới: report đọc kem_lich_su như một biến rời, không phải data['kem_lich_su'] — lát nữa sẽ rõ vì sao.

Trên giao diện, action của wizard đặt target="new" nên nó bật ra thành hộp thoại:

Ảnh chụp hộp thoại Odoo thật tiêu đề In thẻ theo hạng, dòng Hạng thẻ chọn Bạc, dòng In kèm lịch sử tích điểm có ô tích được bật, dòng Số thẻ khớp hiển thị số 3, dưới cùng là hai nút In PDF màu tím và Huỷ

Hình 2: Hộp thoại wizard thật trong Odoo 19. Người dùng chọn Hạng thẻ (Bạc), bật/tắt In kèm lịch sử tích điểm, và một field tính sẵn Số thẻ khớp cho biết sẽ in bao nhiêu thẻ (ở đây 3). Bấm In PDF là action_in chạy, xuất PDF của cả 3 thẻ hạng Bạc trong một lần.

Field "Số thẻ khớp" là một compute nhỏ chạy search_count theo hạng đang chọn — kiểu phản hồi tức thời này rất đáng thêm vào wizard để người dùng biết trước phạm vi trước khi bấm.

Cái bẫy: data không vào QWeb dưới tên data

Đây là chỗ tôi vấp. Theo thói quen, tôi viết trong template:

<t t-set="kem_ls" t-value="data.get('kem_lich_su', True) if data else True"/>

Và nó luôn hiện lịch sử, dù wizard gửi kem_lich_su=False. Đào vào nguồn ir_actions_report._get_rendering_context mới hiểu: Odoo lấy dict data bạn truyền, cập nhật thêm docs, doc_ids, doc_model vào chính nó, rồi đưa cả dict đó làm bộ biến top-level cho QWeb. Tức là các khoá của data bị trải phẳng thành biến rời — trong template có biến kem_lich_su, chứ không có biến tên data. Biểu thức if data else True của tôi tham chiếu một cái tên không tồn tại, QWeb coi như rỗng → luôn rơi vào nhánh True.

Sửa lại cho đúng là đọc thẳng biến rời, kèm mặc định cho trường hợp in không qua wizard (khi đó biến vắng mặt):

<t t-set="kem_ls" t-value="kem_lich_su if kem_lich_su is not None else True"/>
<div t-if="kem_ls">
    <h3>Lịch sử tích điểm</h3> ...
</div>

Đo lại bằng ba cách render, con số khớp mong đợi:

data={'kem_lich_su': True}   ->  "Lịch sử tích điểm" xuất hiện 3 lần (3 thẻ)
data={'kem_lich_su': False}  ->  0 lần  (ẩn hẳn)
không truyền data (in thẳng) ->  3 lần  (mặc định vẫn in kèm)

Kết quả: cùng thẻ, data quyết định nội dung

Cùng thẻ VIP-0001, chỉ khác giá trị kem_lich_su mà wizard gửi:

Ảnh so sánh hai trang PDF thẻ thành viên VIP-0001 đặt cạnh nhau trên nền kem, cột trái nhãn kem_lich_su bằng True có bảng lịch sử tích điểm với ba dòng ngày điểm cộng ghi chú và tổng cộng, cột phải nhãn kem_lich_su bằng False không còn bảng lịch sử mà đi thẳng từ mã QR và mã vạch xuống khối chính sách rồi lời cảm ơn, cả hai đều có mã QR và mã vạch Code128 VIP-0001 và chân trang công ty giống nhau

Hình 3: Cùng một thẻ, cùng một template — chỉ khác data wizard gửi vào. Bên trái (True) có đầy đủ bảng "Lịch sử tích điểm"; bên phải (False) bảng đó biến mất, tài liệu ngắn lại. Mọi thứ khác (QR, mã vạch, chân trang) giữ nguyên. PDF cũng nhẹ đi tương ứng: 72.024 byte so với 68.410 byte, đo bằng _render_qweb_pdf.

Vài lưu ý khi làm thật

  • report_action(records, data) nhận cả recordset lẫn data. Recordset quyết định in những bản ghi nào; data là tham số phụ điều khiển in ra sao. Ở đây records = các thẻ đúng hạng, data = cờ kèm lịch sử.
  • Chỉ truyền dữ liệu JSON-hoá được trong data (chuỗi, số, bool, list, dict). Đừng nhét recordset vào data — recordset đi qua tham số records, còn data để tuần tự hoá được khi Odoo dựng URL tải PDF.
  • Chặn trường hợp rỗng bằng UserError. Chọn hạng không có thẻ nào mà cứ gọi report sẽ ra PDF trắng khó hiểu; báo lỗi rõ ràng tốt hơn.
  • Nhớ cấp quyền cho model wizard trong ir.model.access.csv — TransientModel vẫn cần dòng quyền cho base.group_user, thiếu là người dùng mở hộp thoại liền báo lỗi truy cập.

Ba ý mang về

  1. Wizard TransientModel là nơi hỏi tham số trước khi in: nút của nó trả về report_action(records, data=...) — cùng action như nút In, nhưng bạn tự chọn bản ghi và kèm tham số.
  2. data KHÔNG là một biến trong QWeb — Odoo trải các khoá của nó thành biến top-level. Đọc kem_lich_su trực tiếp, đừng data.get('kem_lich_su'); và đặt mặc định cho trường hợp in không qua wizard.
  3. Tách vai trò rõ ràng: records chọn in gì, data (JSON-hoá được) điều khiển in thế nào; chặn rỗng bằng UserError và nhớ khai quyền cho model wizard.

Wizard vừa in cả 3 thẻ hạng Bạc trong một lần — mà mỗi thẻ lại ra một trang riêng. Làm sao gộp nhiều bản ghi gọn vào một tài liệu, ngắt trang cho đẹp? Phần sau bàn về in nhiều bản ghi vào một file và cách kiểm soát ngắt trang trong QWeb.