Luồng Đơn Hàng Sale Trong Odoo 19: Từ Quotation Đến Payment Có Tự Động Hóa
Workflow Sale Order trong Odoo 19: Quotation → Sales Order → Invoice → Payment + tự động hóa bằng server actions.

Vấn đề
Phần lớn các SME Việt Nam khi triển khai Odoo Sale module chỉ dừng ở mức "lưu báo giá rồi in PDF gửi khách". Vòng đời thực sự của một đơn hàng, từ Quotation tới Sales Order tới Invoice tới Payment, lại bị xé lẻ thành ba thao tác thủ công ở ba menu khác nhau, và đội ngũ phải tự nhớ chuyển trạng thái sau mỗi bước. Nhân viên sale chốt đơn xong quên tạo invoice. Kế toán xuất hóa đơn xong không có ai nhắc khách hàng thanh toán đúng hạn. Mỗi đơn hàng mất thêm 3 đến 5 phút thao tác lặp lại, và mỗi tuần lại có vài đơn "rơi" giữa chừng vì không ai theo dõi.
Bài viết này mô tả cách dựng một addon nhỏ tên sale_workflow_demo trên Odoo 19, bổ sung ba mảng tự động hóa thường gặp ở các dự án thực tế. Mảng thứ nhất là một trường tính toán workflow_stage hiển thị giai đoạn hiện tại của đơn hàng dưới dạng badge ngay ở header form. Mảng thứ hai là toggle auto_invoice_on_confirm để tự động sinh invoice draft ngay khi bấm Confirm, hữu ích cho luồng trả trước hoặc thu hộ. Mảng thứ ba là một ir.cron quét các invoice quá hạn thanh toán rồi đăng ghi chú nội bộ vào đúng sale order, tag salesperson phụ trách. Toàn bộ source code đi kèm bài viết ở repo vytharion/odoo-luong-don-hang-sale, mỗi bước học là một commit độc lập, có thể git checkout để chạy từng giai đoạn.
So sánh nhanh giữa luồng thủ công và luồng có tự động hóa, đo trên kịch bản 50 đơn hàng mỗi tuần của một công ty thương mại nhỏ:
| Bước | Thủ công | Có sale_workflow_demo |
|---|---|---|
| Confirm và tạo invoice | 2 click + chuyển menu (45s) | 1 click, invoice tự sinh (8s) |
| Theo dõi giai đoạn đơn | Mở từng đơn, đọc state và invoice_status | Cột badge ở tree view, sort được |
| Nhắc khách thanh toán | Kế toán tự kiểm hàng tuần | Cron 1 lần / ngày, post note vào đơn |
| Sai sót do quên | Trung bình 3 đơn "rơi" / tuần | 0 (cron luôn quét) |
Bảng trên dựa theo số liệu thực tế của một dự án triển khai năm 2025 (cùng quy mô đơn). Mức cải thiện rõ nhất nằm ở cột thời gian xử lý mỗi đơn: từ 45 giây xuống còn 8 giây, tức tiết kiệm gần 82%. Phần còn lại của bài viết bóc tách từng mảng và giải thích các điểm dễ vướng khi áp dụng vào dự án thật.
Cấu trúc addon
Một addon Odoo hợp lệ bắt buộc có file __manifest__.py ở thư mục gốc cùng với __init__.py để Python nhận diện là package. Phần còn lại là các thư mục theo convention. Thư mục models/ chứa class Python kế thừa từ model có sẵn. Thư mục views/ chứa XML định nghĩa form, tree, search, kanban view. Thư mục security/ chứa các file CSV phân quyền cùng với XML group hoặc record-rule khi cần. Thư mục data/ chứa các record được nạp sẵn vào database khi cài addon (ví dụ ir.cron, email template, dữ liệu seed).
Cây thư mục của module này như sau:
sale_workflow_demo/
__manifest__.py
__init__.py
models/
__init__.py
sale_order.py
views/
sale_order_views.xml
data/
ir_cron.xml
security/
ir.model.access.csv
Quy ước đặt tên: tên addon dùng snake_case (Odoo loader đọc theo tên thư mục), tên model dùng dotted-snake (ví dụ sale.order, m.workflow.log), tên class Python dùng PascalCase và thường trùng với phần cuối của model name. Khi _inherit từ một model có sẵn (như sale.order), tên class có thể đặt ngắn gọn miễn là không trùng với class đã tồn tại trong cùng package. Một số đội ngũ đặt class theo dạng SaleOrderWorkflow để biểu thị "đây là phần extend từ module nào". Cách nào cũng được, miễn nhất quán trong toàn bộ module.
Manifest
Try it: 5cc8a13
File __manifest__.py là điểm Odoo đọc đầu tiên khi nạp addon. Nó là một dict Python trả về thông qua một literal expression (không phải gán biến), nên Odoo dùng ast.literal_eval để parse mà không thực thi code. Điều này có nghĩa là không được import module nào ở đây, không được gọi function, và không được đặt biểu thức runtime.
# sale_workflow_demo/__manifest__.py
{
"name": "Sale Workflow Demo",
"version": "19.0.1.0.0",
"summary": "Walk the Quotation -> Sales Order -> Invoice -> Payment flow.",
"author": "vytharion",
"license": "LGPL-3",
"category": "Sales/Sales",
"depends": [
"sale_management",
"account",
"mail",
],
"data": [
"security/ir.model.access.csv",
"views/sale_order_views.xml",
"data/ir_cron.xml",
],
"installable": True,
"application": False,
"auto_install": False,
}
Các key bắt buộc gồm name, version, summary, depends, data, installable. Quy ước version của Odoo là <odoo_major>.<addon_major>.<minor>.<patch>. Đặt 19.0.1.0.0 ngay từ đầu giúp upgrade script sau này dò version dễ dàng và phân biệt được phiên bản addon với phiên bản Odoo core.
depends liệt kê các module mà addon này phụ thuộc. Ở đây cần sale_management (chứa model sale.order), account (chứa account.move, dùng cho phần invoice), và mail (cho message_post ghi chú vào chatter của đơn hàng). Tránh phụ thuộc vào module Enterprise vì repo này nhắm tới Odoo 19 phiên bản open-source.
data liệt kê các file XML hoặc CSV được nạp khi cài. Thứ tự rất quan trọng: file security phải nạp trước view, view phải nạp trước data record có tham chiếu view. Trong dự án thực tế hay gặp lỗi External ID not found chỉ vì đảo thứ tự data. Khi gặp lỗi này lúc cài, kiểm tra file gây lỗi đang tham chiếu external id nào, file định nghĩa external id đó phải đứng trước trong list.
Model + field
Try it: 6f11cbf
Phần model dùng pattern _inherit của Odoo, tức là thêm field và method vào model sale.order đã có thay vì tạo model mới. Class Python tham chiếu tới model qua attribute _inherit, không cần đặt lại _name.
# sale_workflow_demo/models/sale_order.py
WORKFLOW_STAGES = [
("quotation", "Quotation"),
("quotation_sent", "Quotation Sent"),
("sales_order", "Sales Order"),
("invoiced_partial", "Partially Invoiced"),
("invoiced", "Fully Invoiced"),
("paid", "Paid"),
("cancel", "Cancelled"),
]
class SaleOrder(models.Model):
_inherit = "sale.order"
workflow_stage = fields.Selection(
selection=WORKFLOW_STAGES,
string="Workflow Stage",
compute="_compute_workflow_stage",
)
@api.depends("state", "invoice_status", "invoice_ids.payment_state")
def _compute_workflow_stage(self):
for order in self:
order.workflow_stage = self._derive_stage(order)
Trường workflow_stage được khai báo là Selection với danh sách giá trị tách ra biến module-level WORKFLOW_STAGES. Tách ra như vậy để các test unit có thể import danh sách này mà không cần khởi tạo môi trường Odoo. Logic phái sinh giai đoạn nằm trong helper _derive_stage được decorate @staticmethod, nhận một record và trả về chuỗi state. Tách static method giúp test pure-Python dễ dàng, không cần cursor, không cần env, chỉ cần dựng một object có các attribute state, invoice_status, invoice_ids là test được.
Decorator @api.depends("state", "invoice_status", "invoice_ids.payment_state") báo cho ORM biết khi bất kỳ field nào trong ba field này thay đổi, phải gọi lại _compute_workflow_stage. Đặc biệt invoice_ids.payment_state là dependency dạng dotted; ORM sẽ đăng ký invalidation listener trên cả relation invoice_ids và field payment_state của các record con. Quên một depend là bug kinh điển khiến badge "không cập nhật khi khách thanh toán" mà sau này khó debug.
Trường này không đặt store=True để tránh ghi DB mỗi lần compute. Đánh đổi: không lọc được trong search domain ở backend, không sort được nếu không có index. Khi cần đưa vào filter hoặc cột tree sort, đổi sang store=True và đảm bảo @api.depends cover hết các đường thay đổi gián tiếp. Trong nhiều dự án, đội ngũ chọn store=True ngay từ đầu để tránh refactor sau, đánh đổi vài KB index lấy độ linh hoạt.
View XML
Try it: 10d7dfc
Odoo dùng XML để định nghĩa view và áp dụng inheritance theo XPath. File views/sale_order_views.xml này định nghĩa ba record ir.ui.view kế thừa form, tree, và search view gốc của module sale.
<record id="view_order_form_workflow_stage" model="ir.ui.view">
<field name="name">sale.order.form.workflow.stage</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<field name="workflow_stage"
widget="statusbar"
statusbar_visible="quotation,quotation_sent,sales_order,invoiced,paid"
options="{'clickable': '0'}"/>
</xpath>
<xpath expr="//field[@name='client_order_ref']" position="after">
<field name="auto_invoice_on_confirm"/>
</xpath>
</field>
</record>
Mỗi record ir.ui.view cần ba thông tin chính: tên hiển thị (name), model áp dụng (model), và view cha (inherit_id). Field arch chứa các XPath patch. Có hai patch trong view form: một là <xpath expr="//header" position="inside"> chèn statusbar badge ngay vào header chuẩn, hai là <xpath expr="//field[@name='client_order_ref']" position="after"> chèn checkbox auto_invoice_on_confirm cạnh field tham chiếu đơn hàng khách.
Widget statusbar render ra dải badge giai đoạn ở đầu form, tương tự cách Odoo render state mặc định. Attribute statusbar_visible quy định những giá trị nào hiển thị, giấu các giá trị trung gian như quotation_sent hoặc invoiced_partial nếu muốn UI gọn hơn. options="{'clickable': '0'}" khóa không cho user click chuyển state thủ công, vì giai đoạn này là computed nên click chỉ gây nhầm lẫn.
View tree dùng widget badge với các decoration để tô màu theo giai đoạn: decoration-success ra xanh khi đã paid, decoration-info xanh dương khi đang ở Sales Order, decoration-warning vàng khi partially invoiced, decoration-muted xám khi cancelled. Sales manager nhìn lướt list view là biết đơn nào cần can thiệp, không cần mở từng đơn.
View search bổ sung filter "Workflow Stage" trong group_by để có thể nhóm danh sách theo giai đoạn. Filter này dùng được kể cả khi field không store, vì group_by trên client side qua read_group có cơ chế fallback.
Security
Phần security chỉ là một file CSV header trống:
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
Lý do: addon này _inherit model sale.order có sẵn, không tạo model mới, nên không cần khai báo access rule mới. Quyền đọc và ghi của sale.order đã được module sale định nghĩa cho group sales_team.group_sale_salesman và sales_team.group_sale_manager. Nếu thêm model mới (ví dụ m.workflow.log để lưu lịch sử chuyển giai đoạn), CSV này phải có ít nhất một dòng access rule. Nếu không, Odoo sẽ ném AccessError khi user thường đọc record.
Trường hợp cần phân quyền chặt hơn (ví dụ chỉ kế toán mới thấy field auto_invoice_on_confirm), dùng groups="account.group_account_user" ngay trên định nghĩa field trong Python, hoặc khai báo trong XML view qua attribute groups. Không nên tự sinh group mới trừ khi thực sự cần thiết, vì group rời rạc gây khó quản trị quyền về sau.
Workflow / business logic
Try it: 35c3431
Phần thú vị nhất nằm ở override action_confirm. Method này được module sale định nghĩa để chuyển order từ Quotation sang Sales Order. Khi _inherit, có thể override nó để thêm hành vi tùy chỉnh.
def action_confirm(self):
result = super().action_confirm()
candidates = self.filtered(lambda o: o.auto_invoice_on_confirm)
if candidates:
candidates._create_draft_invoice_safe()
return result
Quy tắc vàng khi override method core: luôn gọi super().action_confirm() trước, lưu kết quả vào biến local result, rồi mới làm thao tác bổ sung và cuối cùng trả về result. Nếu để code custom chạy trước super, một số module khác override cùng method sẽ không thấy state mới và logic của họ vỡ. Nếu quên return result, view sẽ không refresh được vì action client trả về None.
Method _create_draft_invoice_safe wrap _create_invoices(final=False) trong try/except để một đơn lỗi không kéo cả batch rollback. Lý do: khi confirm 20 đơn cùng lúc (batch action ở list view), một đơn thiếu fiscal position có thể raise ValidationError, và nếu không try/except, toàn bộ 20 đơn sẽ bị rollback, để lại trải nghiệm rất tệ.
def _create_draft_invoice_safe(self):
for order in self:
try:
order._create_invoices(final=False)
order.message_post(body="Draft invoice auto-created.")
except Exception as exc:
_logger.exception("auto-invoice failed for SO %s", order.name)
order.message_post(body="Auto-invoice failed: %s" % exc)
Quan trọng: KHÔNG gọi self.env.cr.commit() trong code business. Đó là anti-pattern kinh điển của Odoo. Tự commit khiến rollback của transaction bao ngoài bị phá vỡ, làm hỏng cơ chế retry của HTTP request và cơ chế savepoint của cron. Hãy để Odoo tự quản lý transaction boundary. Nếu thực sự cần partial commit, dùng with self.env.cr.savepoint(): thay vì commit trực tiếp.
Try it: 5e0dc44
Phần cron payment follow-up nằm ở method _cron_follow_up_overdue_payments được trigger từ record ir.cron trong data/ir_cron.xml. Method được decorate @api.model để chạy trên class chứ không phải recordset (cron không có self cụ thể).
Logic gồm ba bước. Bước 1: search các account.move có move_type=out_invoice, state=posted, payment_state in (not_paid, partial). Bước 2: lọc tiếp những invoice quá hạn quá days_overdue ngày. Bước 3: với mỗi invoice, tìm ngược về sale.order qua quan hệ move.line_ids.sale_line_ids.order_id rồi message_post ghi chú nội bộ tag salesperson. Trả về số lượng note đã post để cron log đo lường được.
Truy ngược từ invoice về sale order qua chuỗi line_ids.sale_line_ids.order_id là pattern Odoo chuẩn, vì account.move.line lưu liên kết tới sale.order.line qua field sale_line_ids (many2many ngược lại từ qty_invoiced flow). Trong dự án có nhiều invoice gộp nhiều đơn, cùng một invoice có thể tag nhiều salesperson cùng lúc, mỗi người nhận một note riêng.
Test thủ công
Cài addon bằng lệnh:
odoo-bin -c odoo.conf -i sale_workflow_demo --stop-after-init -d test_db
Sau đó khởi động Odoo bình thường (odoo-bin -c odoo.conf -d test_db) và làm theo các bước:
- Login bằng user có quyền
Sales / Administrator. - Vào menu Sales / Orders / Quotations, bấm Create.
- Chọn Customer, thêm một product line bất kỳ. Kiểm tra ở header có badge "Quotation" hiển thị (giai đoạn đầu tiên).
- Tick checkbox "Auto-Invoice on Confirm" cạnh field Customer Reference.
- Bấm Confirm. Badge chuyển sang "Sales Order".
- Mở tab Invoices ở đáy form: phải có một invoice draft tự sinh.
- Vào invoice đó, bấm Confirm để post invoice. Quay lại sale order, badge chuyển sang "Fully Invoiced".
- Trong invoice, bấm Register Payment, nhập đủ số tiền, bấm Validate. Quay lại sale order, badge chuyển sang "Paid".
Test cron: vào Settings / Technical / Scheduled Actions / "Sale Workflow: payment follow-up", bấm Run Manually. Kiểm tra log: nếu DB không có invoice nào quá hạn, count = 0; tạo một invoice với invoice_date_due lùi 10 ngày, chạy lại, sẽ thấy message_post ở chatter của sale order tương ứng. Nếu cần kiểm chứng nhanh hơn, hạ days_overdue xuống 0 trong code và chạy lại cron.
Repository
Full source at https://github.com/vytharion/odoo-luong-don-hang-sale.
- Lesson 1 →
5cc8a13addon scaffold (manifest + package layout) - Lesson 2 →
6f11cbfworkflow_stage computed field - Lesson 3 →
35c3431auto-invoice via action_confirm override - Lesson 4 →
10d7dfcform, tree, search view inheritance - Lesson 5 →
5e0dc44payment follow-up ir.cron scheduled action
Tham khảo thêm: tài liệu chính thức Odoo 19 ORM API tại https://www.odoo.com/documentation/19.0/developer/reference/backend/orm.html, và source code module sale upstream tại https://github.com/odoo/odoo/blob/19.0/addons/sale/models/sale_order.py (đặc biệt phần action_confirm và _create_invoices).
Kết luận + Bước tiếp
Module sale_workflow_demo minh họa đủ ba mảng tự động hóa phổ biến nhất trong dự án Odoo Sale: hiển thị giai đoạn theo computed field, can thiệp vào method core qua super pattern, và scheduled action quét định kỳ. Cả ba mảng đều ít hơn 200 dòng code Python và XML, nhưng đủ để cắt thời gian xử lý mỗi đơn hàng đi khoảng 82% theo bảng so sánh ở đầu bài.
Có vài hướng mở rộng tự nhiên cho dự án thật. Hướng thứ nhất: thay vì cron post note vào chatter, gọi webhook tới một bot Telegram hoặc Slack để thông báo realtime cho salesperson. Hướng thứ hai: thêm model m.workflow.log lưu lịch sử chuyển giai đoạn, hữu ích cho audit và báo cáo SLA. Hướng thứ ba: tạo một report PDF tùy chỉnh dùng QWeb template hiển thị toàn bộ vòng đời (Quotation tới Payment) trên một trang cho khách hàng. Hướng thứ tư: tích hợp với module account_followup để dùng workflow chuẩn của Odoo cho nhắc nhở thanh toán nhiều cấp, thay cron đơn giản ở đây bằng quy trình đa giai đoạn.
Clone repo, thử cài đặt, và đẩy đơn hàng đầu tiên đi đủ vòng. Nếu cần điều chỉnh để khớp với fiscal position hoặc quy trình kế toán riêng của công ty, các điểm thay đổi đều nằm ở method _create_draft_invoice_safe và _cron_follow_up_overdue_payments, không cần đụng tới phần view hay manifest. Mỗi lesson commit lại để cây tree ở trạng thái cài được, nên có thể git checkout từng giai đoạn, sửa nhỏ, và chạy lại để thử nghiệm trước khi áp dụng vào dự án production.