Thao tác cuối của bộ CRUD là xóa — unlink(). Nó đơn giản khi bản ghi đứng một mình, nhưng đời thực thì bản ghi thường đang được thứ khác tham chiếu: khách hàng có đơn hàng, sản phẩm nằm trong hóa đơn. Lúc đó unlink bị chặn — và đó là điều tốt. Bài này khép lại chương ORM cơ bản bằng cách giải thích cơ chế chặn đó và cách xử lý đúng.
unlink xóa cả recordset
Như write, unlink chạy trên cả recordset bằng một câu SQL DELETE:

Hình 1: unlink() xóa cả tập, trả True khi thành công. Bản ghi đứng riêng (không ai tham chiếu) xóa được ngay. Nhưng khi bản ghi đang được dùng, có hai kiểu chặn: UserError (Odoo tự chặn, kèm lời giải thích) và ForeignKeyViolation (CSDL chặn ở tầng khóa ngoại). Cách xử lý đúng thường là lưu trữ (active = False) thay vì xóa.
Hai kiểu chặn — nhìn lỗi thật
Đây là output thật trên db blog19, cho thấy cả xóa thành công lẫn hai kiểu chặn:

Hình 2: Output thật. (1) Bản ghi mới tạo, đứng riêng — unlink xong, exists() trả False. (2) Xóa đối tác "Nguyễn Văn An" (đang có đơn S00015) → UserError: Odoo chặn có chủ đích kèm giải thích rõ ràng. (3) Xóa quốc gia VN (8 đối tác + các tỉnh tham chiếu) → ForeignKeyViolation: PostgreSQL chặn ở tầng khóa ngoại res_country_state_country_id_fkey.
UserError vs ForeignKeyViolation
Hai kiểu chặn khác nhau về tầng:
UserError— Odoo chủ động kiểm tra trong code (thường ởunlinkoverride hoặc@api.ondelete) rồi chặn với thông báo dễ hiểu cho người dùng. Đây là cách "lịch sự".ForeignKeyViolation— PostgreSQL chặn ở tầng khóa ngoại khi Odoo không chặn trước. Thông báo là thuật ngữ CSDL (tên ràng buộc..._fkey), khó hiểu với người dùng cuối.
Khi tự viết model, nên chủ động chặn bằng UserError với thông báo rõ, thay vì để lỗi CSDL thô nổ ra.
@api.ondelete — chặn xóa đúng cách (Odoo 15+)
Để chặn xóa có điều kiện, dùng decorator @api.ondelete:
from odoo import api, models
from odoo.exceptions import UserError
class TheThanhVien(models.Model):
_inherit = 'quan.the.thanh.vien'
@api.ondelete(at_uninstall=False)
def _chan_xoa_the_dang_dung(self):
for the in self:
if the.diem_tich_luy > 0:
raise UserError("Không thể xóa thẻ còn điểm tích lũy: %s" % the.name)
@api.ondelete chạy trước khi xóa. at_uninstall=False để không chặn khi gỡ module (lúc đó xóa là đúng).
Nên xóa hay lưu trữ?
Nguyên tắc thực tế: dữ liệu đã tham gia nghiệp vụ thì đừng xóa cứng — hãy lưu trữ (archive):
partner.active = False # ẩn khỏi giao diện, giữ nguyên mọi tham chiếu
Trường active là quy ước của Odoo: các truy vấn mặc định lọc active = True, nên bản ghi "biến mất" khỏi danh sách nhưng đơn hàng cũ vẫn trỏ tới được, báo cáo lịch sử vẫn đúng. Đây là lý do nút trên giao diện thường là "Lưu trữ" chứ không phải "Xóa".
Ràng buộc ondelete trên trường Many2one
Khi khai trường Many2one, bạn quyết định điều gì xảy ra khi bản ghi đích bị xóa:
partner_id = fields.Many2one('res.partner', ondelete='restrict') # chặn xóa (mặc định của nhiều trường)
partner_id = fields.Many2one('res.partner', ondelete='cascade') # xóa theo (xóa cha → xóa con)
partner_id = fields.Many2one('res.partner', ondelete='set null') # để trống trường khi cha bị xóa
cascade hữu ích cho dòng con (order line xóa theo order), nhưng dùng sai là mất dữ liệu dây chuyền — cân nhắc kỹ.
Nhớ ba ý
unlink()xóa cả recordset bằng mộtDELETE; bản ghi đứng riêng xóa được, bản ghi đang được tham chiếu bị chặn.- Hai kiểu chặn:
UserError(Odoo chủ động, thông báo rõ) vàForeignKeyViolation(CSDL, thuật ngữ khó hiểu) — tự viết model nên chặn bằng@api.ondelete+UserError. - Dữ liệu đã dùng thì lưu trữ (
active = False) thay vì xóa cứng; trường Many2one khaiondelete='restrict'/'cascade'/'set null'để định đoạt số phận khi bản ghi đích bị xóa.
Xóa xong khép lại CRUD. Nhưng có một thao tác họ hàng rất hay dùng: nhân bản. Phần sau đi vào copy() — cách Odoo sao chép một bản ghi, và dùng default để chỉnh giá trị khi nhân bản.