Odoo 19: những cái bẫy đã dính (ghi từ dự án thật)

@Nguyễn Ngô Thượng//~14 phút đọc0
Chia sẻ:
Odoo 19: những cái bẫy đã dính (ghi từ dự án thật)

Bài này khác các bài còn lại trong series: nó viết cho lập trình viên, không cho chủ doanh nghiệp.

Nội dung là những lỗi đã thật sự xảy ra khi xây bộ module kế toán Việt Nam trên Odoo 19, không phải lý thuyết. Mỗi mục là một lần mất thời gian hoặc suýt hỏng dữ liệu. Đặc điểm chung của phần lớn chúng: hệ thống trông vẫn khỏe, log không báo gì bất thường, và chỉ số bề mặt thì xanh.

ℹ️ Info: Bối cảnh: Odoo 19 Community, chạy Docker, có module tùy chỉnh cho kế toán VAS và POS. Một số bẫy chỉ xuất hiện ở Odoo 19; số khác đúng với mọi phiên bản nhưng đắt hơn ở 19 vì API đã đổi tên.

0. Quy tắc nền: trí nhớ từ Odoo 16, 17, 18 không dùng được

Trước khi vào từng bẫy, đây là quy tắc bao trùm: luôn grep trong mã nguồn Odoo để xác nhận tên field, xmlid và method trước khi gọi. Sai một tên là module không cài được, hoặc tệ hơn — link chết im lặng.

Một số thay đổi đã dính:

Tưởng có Thực tế ở Odoo 19 Triệu chứng khi sai
res.users.groups_id Đổi thành group_ids Tạo user trong test bị lỗi
res.groups.category_id Đổi thành privilege_id, kèm model mới res.groups.privilege Nhóm quyền hiện sai chỗ trong Cài đặt
stock.move.name Bỏ, dùng description_picking ValueError: Invalid field 'name'
stock.dashboard_open_quants Không tồn tại, dùng stock.stock_quant_action Link đá về trang chủ, không báo lỗi
stock.view_location_search có field name Chỉ có complete_name Element cannot be located
stock.stock_location_inventory Không còn xmlid cố định — mỗi công ty một địa điểm riêng ValueError: External ID not found

Dòng thứ tư đáng chú ý nhất vì nó không báo lỗi. Bạn bấm menu, trang mở ra, và bạn tưởng nó chạy.

1. POS chết trắng vì một dòng tưởng vô hại

🔴 Đây là cái bẫy tốn tiền nhất trong nhóm.

Trong Odoo, method khai báo các field mà màn bán hàng cần nạp có một quy ước ngầm: trả về danh sách rỗng nghĩa là "nạp mọi field".

Nên khi bạn viết đoạn có vẻ rất hợp lý này để thêm một field của mình:

def _load_pos_data_fields(self, config):
    return super()._load_pos_data_fields(config) + ['truong_cua_toi']

...và model gốc vốn trả về danh sách rỗng, bạn vừa biến "nạp tất cả" thành "chỉ nạp đúng một field vừa cộng vào". Toàn bộ field gốc của Odoo biến mất.

pos.order chính là model để rỗng như vậy.

Hậu quả đã xảy ra: gói dữ liệu POS chỉ còn hai field, mất lines, partner_id, amount_total. Trình duyệt văng TypeError: Cannot read properties of undefined (reading 'map')màn bán hàng không bao giờ hiện ra. Mất ba lượt đi đoán sai nguyên nhân — tốc độ máy chủ, rồi nén, rồi bộ nhớ trình duyệt — vì không có cách nhìn vào trình duyệt.

Cách viết đúng:

def _load_pos_data_fields(self, config):
    fields = super()._load_pos_data_fields(config)
    if not fields:      # rỗng = nạp tất, vốn đã gồm field của mình
        return fields
    return fields + ['truong_cua_toi']

Và kiểm cho đúng chỗ. Đừng khẳng định bằng cách assert field của mình có trong danh sách trả về — bài kiểm kiểu đó vẫn xanh trong khi tính năng đã chết. Phải kiểm ở cấu trúc dữ liệu cuối mà client nhận được, đồng thời khẳng định field gốc như lines còn nguyên.

2. Màn bán hàng treo vĩnh viễn vì một tab khác đang mở

🔴 Bẫy tốn nhiều thời gian nhất, vì máy chủ hoàn toàn khỏe — mọi lời gọi dưới một giây, nhìn log không thấy gì bất thường.

Cơ chế: thêm một model mới vào POS làm kho lưu trong trình duyệt (IndexedDB) thiếu bảng, nên Odoo đóng kho và mở lại ở phiên bản cao hơn. Nâng cấp IndexedDB chỉ chạy được khi không còn kết nối nào khác. Nếu còn một tab POS khác đang giữ kho, trình duyệt phát sự kiện blocked — mà mã nguồn POS của Odoo chỉ bắt onerror, onsuccessonupgradeneeded, không bắt onblocked. Kết quả: hàm chờ sẵn sàng không bao giờ được gọi, lời hứa khởi tạo treo mãi.

Dấu hiệu nhận ra trong log: chuỗi gọi dừng hẳn sau pos.session/load_data_params, và không hề có pos.session/load_data. Thấy đúng dấu hiệu này thì đừng đi đo tốc độ máy chủ nữa.

Xử lý: đóng hết tab đang mở POS rồi tải lại; bí quá thì xóa dữ liệu site trong Application ▸ Storage.

Liên quan: thêm field hoặc model cho POS thì phải đẩy mốc thay đổi dữ liệu lên. Màn bán hàng chỉ nạp lại khi mốc này mới hơn dữ liệu đã lưu, và mốc đó không tự đổi khi bạn thêm field. Lưu ý khi đẩy: Odoo ghi mốc cũ có phần lẻ giây còn hàm lấy thời gian hiện tại thì cắt cụt phần lẻ — nâng cấp trong cùng một giây thì mốc "mới" hóa ra sớm hơn mốc cũ và không ăn thua. Lấy giá trị lớn hơn giữa "mốc cũ cộng một giây" và "bây giờ".

3. Luật lọc đọc trường trỏ về chính nó là đệ quy

🔴 Một ir.rule đọc trường nhiều-nhiều trỏ về chính model đang bị lọc sẽ tự gọi lại chính mình.

Lý do: đọc một trường Many2many chạy qua hàm tìm kiếm của model bên kia, tức là chạy qua đúng luật lọc đang cần giá trị đó.

Cách thoát: để luật đọc một trường tính toán có cờ tính bằng quyền quản trị, bên trong hàm tính thì tra bằng sudo() — quyền quản trị bỏ qua luật lọc nên vòng lặp đứt.

Ba lưu ý khác về ir.rule từ thực tế:

  • Luật gắn với nhóm chỉ áp cho người trong nhóm. Người ngoài không bị đụng gì — đây là cách an toàn nhất để giao hàng tính năng phân quyền: cài lên không đổi gì cho ai, chỉ khi quản trị viên chủ động xếp người vào nhóm mới bắt đầu lọc.
  • Chặn xem khác chặn ghi. Đặt quyền đọc mà để trống quyền ghi thì luật chỉ giấu dữ liệu. Với kho thì nên vậy: đường ghi còn chạm cả kho ảo Khách hàng và Nhà cung cấp, chặn ghi theo kho là xác nhận phiếu xuất lỗi quyền ngay.
  • Nhớ cho qua giá trị rỗng. Chứng từ không gắn kho mà luật không có nhánh xử lý giá trị rỗng là chặn nhầm hàng loạt.

Bối cảnh nghiệp vụ của phần này: Phân quyền Odoo — record rule và tài khoản cho AI.

4. Khai quyền cho menu là cộng dồn, không phải ghi đè

Khai nhóm quyền trên menuitem dùng cơ chế cộng dồn. Bỏ thuộc tính khai quyền ra khỏi mã nguồn không xóa quyền cũ đã ghi trong database.

Muốn gỡ phải khai tường minh dấu trừ:

groups="group_moi,-account.group_account_readonly,-account.group_account_user"

Không làm vậy thì hệ thống đã cài bản trước vẫn giữ quyền cũ, menu hiện lại cho người không nên thấy — trong khi nhìn mã nguồn thì tưởng đã gỡ xong. Đây là loại lỗi chỉ xuất hiện trên môi trường đã chạy lâu, không bao giờ lộ ra trên database mới tinh.

Liên quan: ir.ui.menu không có trường công ty, nên không thể ẩn menu theo công ty. Muốn giới hạn thì tạo nhóm quyền riêng rồi gán menu vào nhóm đó.

Và khi kiểm menu nào người dùng thấy được, dùng hàm tính danh sách menu hiển thị chứ đừng dùng search()search không lọc theo quyền.

5. Cloudflare vào cổng 443, không phải 80

🔴 Cấu hình nginx chỉ khai listen 80 thì yêu cầu từ Cloudflare rơi sang site khác trên cùng máy — mà nhìn từ ngoài vẫn thấy HTTP 200, rất dễ tưởng đã xong.

Đã dính: một tên miền con trả về trang của một ứng dụng hoàn toàn khác.

Cách phát hiện: so kích thước phản hồi giữa hai cổng, đừng nhìn mã HTTP. Cổng 80 trả 8.313 byte (đúng Odoo), cổng 443 trả 750 byte (site khác). Có chứng chỉ cộng listen 443 là hết.

Hai bẫy hạ tầng đi kèm:

  • Tên miền .app bắt buộc HTTPS vì nằm trong danh sách HSTS dựng sẵn của trình duyệt. Truy cập bằng http:// bị trình duyệt từ chối ngay, không tới được máy chủ. Chứng chỉ không phải tùy chọn mà là điều kiện để vào được.
  • Container Odoo chạy uid 100, không phải 101. Chown filestore nhầm là lỗi quyền, mà lỗi chỉ lộ ra lúc mở trang chứ không phải lúc khởi động. Tương tự, file cấu hình để chmod 600 chủ root thì container không đọc được — chết ngay ở bước chờ database với thông báo NoSectionError: 'options', chẳng liên quan gì tới quyền.

Còn một cái nữa về cấu hình lọc database: nếu bộ lọc khớp nhiều hơn một database và không có phiên đăng nhập, trang đăng nhập rơi vào chuyển hướng vô hạn. Muốn trình duyệt vào thẳng thì để tên database và bộ lọc cùng trỏ đúng một database.

6. Script chạy xong, in ra thành công, database trống trơn

🔴 odoo shell chạy qua ống dẫn hủy giao dịch khi kết thúc. Không gọi commit() tường minh là mọi thứ biến mất — mà script vẫn in ra như đã chạy thành công.

Đã dính: script báo "tạo 143 bản ghi, đối chiếu 36 trên 36 khớp" trong khi bảng trong database trống trơn.

Quy tắc: script ghi dữ liệu phải commit tường minh, và kiểm lại bằng truy vấn SQL trực tiếp chứ đừng tin dòng in ra của chính nó.

Bốn lưu ý khác khi chạy script:

  • odoo shell chạy dưới quyền superuser nên một số hành động bị Odoo chặn cố ý. Không phải lỗi cấu hình — phải chạy dưới danh nghĩa user thật.
  • Thêm thư mục module mới thì phải khởi động lại container. Máy chủ đang chạy không quét lại đường dẫn addons: cài xong ở tiến trình riêng thì database ghi là "đã cài", nhưng tiến trình web vẫn báo không tìm thấy module và mọi trang trả HTTP 500. Sửa module đã có thì không cần, chỉ khi tạo thư mục mới.
  • Số tiền lớn làm vỡ XML-RPC. Giá trị vượt 2³¹, khoảng 2,1 tỷ, phải trả kiểu số thực chứ không phải số nguyên, nếu không sẽ báo vượt giới hạn. Web JSON-RPC thì không sao — nên lỗi chỉ lộ khi gọi qua MCP hoặc API ngoài. Với dữ liệu kế toán Việt Nam thì ngưỡng 2,1 tỷ là con số gặp thường xuyên.
  • Script chạy lại phải idempotent: dùng khóa nguồn ổn định để bỏ qua bản ghi đã xử lý.

Và khi chạy script trên bản sao dữ liệu, thứ tự bắt buộc, thiếu bước nào cũng hỏng: sao lưu database đích trước → phục hồi bản sao kèm filestorenâng cấp module trên bản sao vì bản dump có thể cũ hơn code hiện tại → mới chạy script → tự đối chiếu kết quả và dừng ngay khi lệch. Đổi database xong phải dọn thư mục phiên đăng nhập, nếu không trình duyệt báo phiên hết hạn do phiên nằm ngoài database nên không bị xóa theo.

7. wkhtmltopdf không hiểu flexbox

🔴 display:flex nhìn trên HTML thì đẹp, in ra PDF thì hai cột xếp chồng lên nhau.

Đã dính ở mẫu Ủy nhiệm chi: tiêu đề công ty và ghi chú chồng lên nhau, hai ô ký cũng vậy. Bài kiểm HTML vẫn xanh vì chuỗi ký tự có đủ — chỉ mở PDF ra nhìn mới thấy.

Dựng bố cục ngang bằng <table>. Và kiểm mẫu in phải render PDF thật rồi mở ra xem, không chỉ render HTML.

Hai chi tiết nhỏ đi kèm: chứng từ còn nháp có tên là một dấu gạch chéo, in thẳng ra là tờ giấy có gạch chéo trông như lỗi — nên in dấu chấm để người ta điền tay. Và cảnh báo wkhtmltopdf không tải được tài nguyên ngoài là vô hại, PDF vẫn sinh ra bình thường.

8. Bài kiểm xanh không chứng minh màn hình dựng được

Đây không phải một bẫy kỹ thuật mà là một bài học về cách kiểm.

35 trên 35 bài kiểm Python xanh trong khi màn bán hàng không hiện ra. Bài kiểm Python chỉ chứng minh máy chủ trả đúng dữ liệu; nó không chứng minh trình duyệt dựng được giao diện.

Cách sửa: dựng một script mở giao diện bằng trình duyệt thật, bấm qua các bước, liệt kê từng phần bắt buộc, in mọi lỗi JavaScript và chụp màn hình. Mất khoảng năm phút để dựng, và nó ra ngay dòng lỗi thật.

Bài học lớn hơn, và là thứ tôi muốn để lại cuối bài:

Không nhìn được thì đi dựng cách nhìn, đừng đoán.

Đã mất ba lượt đưa ra ba nguyên nhân sai — tốc độ máy chủ, rồi thiếu nén, rồi bộ nhớ trình duyệt bị chặn — chỉ vì không mở được trang cục bộ để xem. Dựng công cụ quan sát mất năm phút. Đáng lẽ phải làm từ lượt đầu.

Hệ quả thứ hai: người dùng báo lại lần thứ hai nghĩa là giả thuyết của mình sai, không phải họ làm chưa đúng. Đừng bảo họ đóng tab hay xóa bộ nhớ trình duyệt để lấp chỗ mình chưa hiểu.

Vì sao những cái bẫy này đắt hơn bình thường

Nhìn lại tám mục trên, chúng có một đặc điểm chung: không cái nào báo lỗi rõ ràng.

Link đá về trang chủ mà trả HTTP 200. Menu hiện lại cho người không nên thấy mà mã nguồn trông đã gỡ. Nginx trả 200 nhưng là của site khác. Script in ra thành công mà database trống. Bài kiểm xanh mà màn hình chết. PDF hỏng bố cục mà bài kiểm HTML vẫn qua.

Đây chính là lý do chỉ số bề mặt không đủ để nói "xong". Nguyên tắc chúng tôi tự đặt ra sau những lần này: chỉ nói xong khi đã chạy thật và đối chiếu được bằng số; chưa nhìn thấy màn hình thì nói rõ là chưa nhìn thấy; và không bịa số — thiếu tham số thì để trống và hỏi, đừng đoán.

Nguyên tắc đó cũng là lý do vì sao trong toàn bộ series này, chúng tôi giữ ký hiệu 🟡 cho những tính năng chạy đúng trên dữ liệu mẫu nhưng chưa đối chiếu sổ sách thật, thay vì viết là đã hoàn thiện.

Câu hỏi thường gặp

Vì sao màn hình POS Odoo treo mãi ở màn chờ mà máy chủ vẫn khỏe?

Nguyên nhân thường gặp là kho lưu trong trình duyệt (IndexedDB) cần nâng cấp phiên bản sau khi thêm model mới vào POS, nhưng một tab POS khác đang giữ kho nên trình duyệt phát sự kiện blocked — mà mã nguồn POS của Odoo không bắt sự kiện này nên hàm khởi tạo treo vĩnh viễn. Dấu hiệu nhận ra trong log: chuỗi gọi dừng sau load_data_params và không hề có load_data. Cách xử lý là đóng hết tab POS rồi tải lại.

Thêm field vào POS Odoo bị mất hết field gốc là do đâu?

Do method khai báo field cần nạp có quy ước ngầm: trả về danh sách rỗng nghĩa là nạp mọi field. Nếu bạn cộng thêm tên field vào kết quả của super() mà model gốc vốn trả về danh sách rỗng, bạn vô tình biến nó thành chỉ nạp đúng field vừa cộng. Cách đúng là kiểm tra nếu danh sách rỗng thì trả về nguyên trạng.

Vì sao ir.rule trong Odoo gây lỗi đệ quy?

Khi luật lọc đọc một trường Many2many trỏ về chính model đang bị lọc, việc đọc trường đó chạy qua hàm tìm kiếm của model bên kia, tức là chạy qua đúng luật lọc đang cần giá trị. Cách thoát là để luật đọc một trường tính toán có bật cờ tính bằng quyền quản trị, và bên trong hàm tính thì tra bằng sudo().

Script chạy qua odoo shell in ra thành công nhưng dữ liệu không được lưu?

odoo shell chạy qua ống dẫn sẽ hủy giao dịch khi kết thúc. Nếu script không gọi commit() tường minh thì mọi thay đổi biến mất, trong khi script vẫn in ra như đã chạy thành công. Nguyên tắc: script ghi dữ liệu phải commit tường minh và kiểm lại bằng truy vấn SQL trực tiếp thay vì tin vào dòng in ra của chính nó.

Vì sao Odoo sau Cloudflare trả HTTP 200 nhưng ra nội dung của site khác?

Cloudflare kết nối tới máy chủ ở cổng 443. Nếu cấu hình nginx chỉ khai listen 80, yêu cầu rơi sang site khác trên cùng máy mà nhìn từ ngoài vẫn thấy HTTP 200. Cách phát hiện là so kích thước phản hồi giữa hai cổng thay vì nhìn mã trạng thái.

Bài kiểm Python xanh hết có đủ để kết luận tính năng Odoo chạy đúng không?

Không. Bài kiểm Python chỉ chứng minh máy chủ trả đúng dữ liệu, không chứng minh trình duyệt dựng được giao diện. Đã có trường hợp 35 trên 35 bài kiểm xanh trong khi màn bán hàng không hiện ra. Với mọi thay đổi động vào giao diện, cần một script mở trang bằng trình duyệt thật, in lỗi JavaScript và chụp màn hình.

Đọc tiếp trong series

Liên quan về cách làm việc với hệ thống phức tạp:

Nguồn tham khảo

Bài viết hữu ích?

Chia sẻ để nhiều người biết đến!

Chia sẻ:

>_ LLM-Friendly Copy

Copy as Markdown to use with ChatGPT, Claude, or other AI tools

3,111 words|16,269 characters

//Bình luận

Bài viết liên quan

Khám phá thêm những bài viết cùng chủ đề với Odoo 19: những cái bẫy đã dính (ghi từ dự án thật)

Bài viết hữu ích? Hãy kết nối với Diginno!

Chúng tôi giúp doanh nghiệp SME ứng dụng AI và automation vào quy trình làm việc - từ tư vấn chiến lược đến triển khai thực tế.