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.

Như write, unlink chạy trên cả recordset bằng một câu SQL DELETE:

unlink trong Odoo 19 xóa bản ghi và vì sao đôi khi bị chặn: cú pháp recs.unlink chạy DELETE FROM WHERE id IN danh sách trả về True nếu thành công; bản ghi đứng riêng thì xóa được ngay, tmp bằng P.create name Xóa thử rồi tmp.unlink vì không ai tham chiếu nên xóa xong; hai kiểu chặn khi bản ghi đang được dùng, một là UserError Odoo tự chặn có chủ đích kèm lời giải thích ví dụ The partner cannot be deleted because it is used in Accounting, hai là ForeignKeyViolation cơ sở dữ liệu chặn ở tầng khóa ngoại violates foreign key constraint đuôi fkey; xử lý đúng là không xóa cứng dữ liệu đang dùng mà lưu trữ archive bằng cách đặt rec.active bằng False để ẩn đi giữ toàn vẹn tham chiếu, Odoo lọc active True mặc định nên bản ghi biến mất khỏi giao diện

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:

Output unlink chạy thật trên database blog19: một bản ghi đứng riêng xóa được, tmp bằng P.create name Xóa thử ra id 3545 tồn tại True, gọi tmp.unlink rồi P.browse 3545 exists trả False đã xóa sạch; hai partner đang được đơn hàng dùng thì Odoo tự chặn, so bằng SO.search limit 1 ra S00015 partner Nguyễn Văn An id 7, gọi so.partner_id.unlink báo UserError The partner cannot be deleted because it is used in Accounting; ba country VN đang được 8 partner và các state tham chiếu thì CSDL chặn, vn là country VN id 241 có 8 partner đang dùng, gọi vn.unlink báo ForeignKeyViolation update or delete on table res_country violates foreign key constraint res_country_state_country_id_fkey; kết luận dữ liệu đang được dùng thì không xóa cứng hãy archive active bằng False

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 ở unlink override 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 ý

  1. unlink() xóa cả recordset bằng một DELETE; bản ghi đứng riêng xóa được, bản ghi đang được tham chiếu bị chặn.
  2. 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.
  3. Dữ liệu đã dùng thì lưu trữ (active = False) thay vì xóa cứng; trường Many2one khai ondelete='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.