Skip to main content
OdooORMPython

Tài liệu tham khảo: Các phương thức ORM trong Odoo 17

Tài liệu giải thích chi tiết các phương thức ORM (Object-Relational Mapping) thường dùng nhất trong Odoo 17: lý thuyết, khi nào dùng, tại sao dùng, và ví dụ cụ thể có giải thích.

Mọi ví dụ giả định ta đang viết trong một model kế thừa models.Model, ví dụ library.book.


Mục lục

  1. Khái niệm nền tảng cần nắm trước
  2. Nhóm CRUD – Ghi dữ liệu
  3. Nhóm hỗ trợ tạo bản ghi
  4. Nhóm Truy vấn & Đọc dữ liệu
  5. Nhóm Metadata & Kiểm tra
  6. Nhóm Xử lý recordset trong bộ nhớ (in-memory)
  7. Nhóm View
  8. Bảng tổng hợp nhanh
  9. Best practices chung

0. Khái niệm nền tảng

Trước khi đi vào từng phương thức, cần nắm các khái niệm cốt lõi vì chúng quyết định việc khi nàotại sao dùng một phương thức:

  • Recordset (tập bản ghi): Mọi thao tác trong Odoo đều xoay quanh recordset – một "tập hợp" gồm 0, 1 hoặc nhiều bản ghi của cùng một model. self trong một method chính là một recordset. Một recordset có thể lặp (for rec in self).
  • self mang theo Environment: Recordset luôn gắn với một env chứa: cr (con trỏ DB/cursor), uid (user hiện tại), context (dict ngữ cảnh), su (superuser hay không). Đây là lý do mọi method ORM "biết" được ai đang gọi và trong ngữ cảnh nào.
  • Cache (bộ nhớ đệm ORM): Khi bạn đã đọc dữ liệu, Odoo lưu trong cache. Truy cập lại field không bắn thêm SQL. Đây là nền tảng để hiểu vì sao filtered/mapped/sorted (xử lý trên cache) nhanh hơn việc gọi search nhiều lần (bắn SQL).
  • Domain (điều kiện lọc): Một list các tuple (field, operator, value) dùng cho search, ví dụ [('state', '=', 'done'), ('amount', '>', 100)]. Mặc định nối với nhau bằng AND. Toán tử logic dùng tiền tố Ba Lan: '|' (OR), '&' (AND), '!' (NOT).
  • Lệnh trì hoãn (lazy): Nhiều method ORM (search, browse...) không thực sự đọc tất cả dữ liệu ngay; ORM chỉ đọc khi field được truy cập (prefetch). Hiểu điều này giúp tránh tối ưu sai.

Quy ước về decorator hay gặp:

  • @api.model: method cấp model, self là recordset rỗng (không cần record cụ thể) – ví dụ default_get, name_create, search, _name_search.
  • @api.model_create_multi: method create nhận danh sách dict (batch).

Nhóm 1 — CRUD (Ghi dữ liệu)

1.1 create

Lý thuyết create(vals) tạo mới hoàn toàn một (hoặc nhiều) bản ghi và lưu xuống database, trả về recordset của (các) bản ghi vừa tạo. Trong Odoo 17, chuẩn khuyến nghị là override với @api.model_create_multi, khi đó tham số là một list các dict (vals_list) để hỗ trợ tạo hàng loạt hiệu quả.

Khi nào dùng

  • Khi cần thêm dữ liệu mới vào hệ thống (tạo đơn hàng, sản phẩm, khách hàng...).
  • Khi muốn chèn logic nghiệp vụ chạy tại thời điểm tạo (gán mã tự động, set giá trị mặc định phức tạp, kích hoạt thông báo...).

Tại sao dùng

  • Đây là cổng ghi duy nhất khi sinh bản ghi mới: nó kích hoạt default_get, các ràng buộc @api.constrains, tính lại các computed field phụ thuộc, và ghi log audit (create_uid, create_date).
  • Việc override create cho phép đảm bảo dữ liệu luôn hợp lệ ngay từ khi sinh ra.

Ví dụ có giải thích

# Gọi tạo một bản ghi
book = self.env['library.book'].create({
    'name': 'Clean Code',
    'author_id': author.id,
    'price': 45.0,
})
# -> book là recordset chứa đúng 1 record vừa tạo, có book.id thật trong DB.

# Tạo nhiều bản ghi cùng lúc (batch) - hiệu quả hơn gọi create() trong vòng lặp
books = self.env['library.book'].create([
    {'name': 'Book A', 'price': 10},
    {'name': 'Book B', 'price': 20},
])
# -> books là recordset chứa 2 record.

Override để thêm logic – luôn dùng model_create_multi ở Odoo 17:

from odoo import api, models

class LibraryBook(models.Model):
    _inherit = 'library.book'

    @api.model_create_multi
    def create(self, vals_list):
        # vals_list là LIST các dict, kể cả khi chỉ tạo 1 record
        for vals in vals_list:
            if not vals.get('reference'):
                # sinh mã tham chiếu tự động trước khi ghi DB
                vals['reference'] = self.env['ir.sequence'].next_by_code('library.book')
        records = super().create(vals_list)   # gọi tạo thực sự
        records._notify_librarian()            # logic sau khi tạo
        return records

Giải thích: Vòng lặp xử lý từng vals trước super().create() để dữ liệu được vá xong rồi mới ghi DB một lần. Việc gọi super() đảm bảo cơ chế lõi (constraints, computed, audit) vẫn chạy. Sau khi có record thật mới gọi logic phụ.

Lưu ý: Không dùng create({...}) (dict đơn) khi đã override bằng model_create_multi ở vị trí gọi nội bộ phức tạp – nhưng phía gọi vẫn được phép truyền dict đơn, Odoo tự bọc thành list.


1.2 write

Lý thuyết write(vals) cập nhật các field cho toàn bộ record trong recordset hiện tại (self), ghi thay đổi xuống DB. Trả về True. Nó áp dụng cùng một dict thay đổi cho mọi record trong self.

Khi nào dùng

  • Khi sửa dữ liệu của bản ghi đã tồn tại (đổi trạng thái, cập nhật giá, gán quan hệ...).
  • Khi cần can thiệp logic mỗi lần dữ liệu thay đổi (audit, đồng bộ, validate khi chuyển trạng thái).

Tại sao dùng

  • write là cổng cập nhật chuẩn: kích hoạt @api.constrains, tính lại computed field phụ thuộc, ghi write_uid/write_date.
  • Cập nhật theo batch: gọi recordset.write() một lần cho nhiều record tiết kiệm hơn nhiều so với gán field từng record trong vòng lặp.

Ví dụ có giải thích

# Cập nhật 1 record
book.write({'price': 50.0, 'state': 'available'})

# Cập nhật HÀNG LOẠT: mọi book trong recordset thành state 'archived'
books = self.env['library.book'].search([('price', '<', 5)])
books.write({'state': 'archived'})   # 1 câu UPDATE cho cả tập

Cú pháp đặc biệt cho field quan hệ One2many/Many2many (Command):

from odoo import Command

order.write({
    'line_ids': [
        Command.create({'product_id': p.id, 'qty': 2}),  # thêm dòng mới
        Command.unlink(old_line.id),                      # xóa dòng
        Command.update(line.id, {'qty': 5}),              # sửa dòng đang có
    ],
})

Override để chèn logic:

def write(self, vals):
    if 'state' in vals and vals['state'] == 'done':
        self._check_can_be_done()       # validate trước khi đổi trạng thái
    res = super().write(vals)           # ghi thật
    if 'price' in vals:
        self._log_price_change(vals['price'])
    return res

Giải thích: Kiểm tra 'state' in vals để chỉ chạy logic khi field đó thực sự bị đổi. So sánh giá trị mới với DB (qua self.price đọc trước super()) nếu cần giá trị cũ.

Khác biệt cốt lõi với create: write luôn cần một recordset đã tồn tại (self có id); create sinh id mới.


1.3 unlink

Lý thuyết unlink() xóa vĩnh viễn các bản ghi trong recordset khỏi database (lệnh SQL DELETE). Trả về True.

Khi nào dùng

  • Khi cần loại bỏ hẳn dữ liệu rác/sai mà không cần lưu vết.
  • Khi dọn dữ liệu tạm, nháp (draft) bị hủy.

Tại sao dùng

  • Là cách chuẩn để xóa, đồng thời tôn trọng các ràng buộc khóa ngoại (ondelete), kích hoạt logic dọn dẹp liên quan, và kiểm tra quyền unlink.

Lưu ý quan trọng

  • Xóa không thể hoàn tác. Trong nhiều nghiệp vụ, người ta dùng archiving (active = False) thay vì unlink để giữ lịch sử.
  • unlink có thể bị chặn nếu record đang được tham chiếu bởi record khác với ondelete='restrict', hoặc nếu record đã được xác nhận/đã khóa.

Ví dụ có giải thích

drafts = self.env['library.book'].search([('state', '=', 'draft')])
drafts.unlink()     # xóa toàn bộ sách ở trạng thái nháp

Override để chặn xóa theo nghiệp vụ:

from odoo.exceptions import UserError

def unlink(self):
    for rec in self:
        if rec.state == 'done':
            raise UserError("Không thể xóa bản ghi đã hoàn tất, hãy lưu trữ thay vì xóa.")
    return super().unlink()

Giải thích: Duyệt từng record để validate, raise UserError (sẽ rollback giao dịch) trước khi gọi super().unlink(). Đây là chỗ phổ biến để thực thi chính sách "chỉ xóa nháp".


1.4 copy

Lý thuyết copy(default=None) nhân bản một bản ghi hiện có thành một bản ghi hoàn toàn mới, trả về recordset của bản sao. Tham số default là dict ghi đè giá trị cho bản sao.

Khi nào dùng

  • Khi người dùng bấm "Duplicate" trên form (Odoo gọi copy ngầm).
  • Khi cần tạo bản ghi mới gần giống một bản ghi mẫu (đơn hàng lặp, template...).

Tại sao dùng

  • Tránh nhập lại thủ công; sao chép cả các dòng con (One2many) theo cấu hình.
  • Cho phép kiểm soát field nào không được sao chép.

Kiến thức quan trọng — thuộc tính copy của field

  • Mặc định, field thường được sao chép. Nhưng nếu khai báo copy=False thì field đó sẽ bị bỏ qua khi copy (ví dụ số phiếu, mã sequence, trạng thái nên reset).
  • copy chỉ yêu cầu đúng 1 record (self.ensure_one() được gọi bên trong).

Ví dụ có giải thích

# Khai báo field không nên copy
class LibraryBook(models.Model):
    _inherit = 'library.book'
    reference = fields.Char(copy=False)   # mã tham chiếu không sao chép

# Nhân bản, đồng thời ghi đè vài field cho bản sao
new_book = book.copy({
    'name': book.name + ' (Copy)',
    'state': 'draft',
})

Giải thích: default={'name': ...} đảm bảo bản sao có tên khác để dễ phân biệt và state được reset về draft. Field reference đã đặt copy=False nên bản sao sẽ trống/được sinh lại, tránh trùng mã.

Override để tùy biến hành vi sao chép:

def copy(self, default=None):
    default = dict(default or {})
    default.setdefault('name', f"{self.name} (Bản sao)")
    return super().copy(default)

Giải thích: dict(default or {}) tạo bản sao an toàn để không sửa nhầm dict của người gọi; setdefault chỉ đặt tên nếu người gọi chưa truyền name.


Nhóm 2 — Hỗ trợ tạo bản ghi

2.1 default_get

Lý thuyết default_get(fields_list) trả về một dict các giá trị mặc định cho danh sách field truyền vào, dùng khi mở form tạo mới. Đây là nơi Odoo gom giá trị mặc định từ: thuộc tính default= của field, context (default_<field>), và logic động bạn tự viết.

Khi nào dùng

  • Khi muốn đặt giá trị mặc định động/phức tạp mà không thể biểu diễn bằng default= tĩnh (phụ thuộc user, công ty, context, ngày hiện tại, bản ghi liên quan...).

Tại sao dùng

  • default=lambda self: ... chỉ hợp cho một field; default_get cho phép tính nhiều field cùng lúc với logic chung và đọc context.
  • Là điểm chuẩn để khởi tạo form theo ngữ cảnh (ví dụ mở tạo dòng từ một đơn cha cụ thể).

Ví dụ có giải thích

@api.model
def default_get(self, fields_list):
    res = super().default_get(fields_list)   # lấy mặc định gốc trước
    # Mặc định gán thủ thư là người đang đăng nhập
    if 'librarian_id' in fields_list:
        res['librarian_id'] = self.env.user.id
    # Nếu mở form từ một danh mục cụ thể qua context
    if 'category_id' in fields_list and self.env.context.get('default_category_id'):
        res['category_id'] = self.env.context['default_category_id']
    return res

Giải thích: Luôn gọi super().default_get() đầu tiên để giữ các mặc định lõi, rồi bổ sung/ghi đè. Kiểm tra field in fields_list để tránh tính field mà form không yêu cầu. Việc đọc context['default_category_id'] cho thấy cách Odoo truyền mặc định qua action.

Mẹo: Bạn có thể truyền mặc định mà không cần override bằng cách dùng context {'default_<field>': value} khi mở action. default_get mới cần khi logic phức tạp.


2.2 name_create

Lý thuyết name_create(name) là phương thức quick-create: chỉ nhận một chuỗi và tạo ngay một bản ghi, trả về tuple (id, display_name). Mặc định nó gán chuỗi đó vào field name (hoặc field _rec_name).

Khi nào dùng

  • Khi người dùng gõ một giá trị mới vào ô Many2one và chọn "Create '<text>'" – Odoo gọi name_create để tạo nhanh bản ghi liên kết mà không mở form.

Tại sao dùng

  • Tăng tốc nhập liệu: tạo đối tượng phụ (tag, danh mục, tác giả...) ngay trong luồng làm việc.
  • Cho phép can thiệp để điền thêm field bắt buộc khi quick-create.

Ví dụ có giải thích

@api.model
def name_create(self, name):
    # Mặc định chỉ set 'name'. Ở đây ta tách "Tên - Mã" để điền thêm field.
    if ' - ' in name:
        real_name, code = name.split(' - ', 1)
        record = self.create({'name': real_name.strip(), 'code': code.strip()})
        return record.id, record.display_name
    return super().name_create(name)

Giải thích: Khi người dùng gõ "Văn học - VH01", ta tách thành namecode rồi create. Trả về (id, display_name) đúng định dạng giao diện cần. Nếu không khớp định dạng, gọi super() để giữ hành vi mặc định.

Lưu ý: Nếu model có field bắt buộc khác name, quick-create sẽ lỗi trừ khi bạn override name_create để điền đủ – hoặc đặt _rec_name và để các field kia có default.


Nhóm 3 — Truy vấn & Đọc dữ liệu

3.1 browse

Lý thuyết browse(ids) chuyển đổi id (hoặc list id) thành recordsetkhông bắn SQL ngay. Nó chỉ tạo "tay cầm" trỏ tới record; dữ liệu chỉ được đọc khi bạn truy cập field.

Khi nào dùng

  • Khi bạn đã biết id (từ API bên ngoài, từ tham số, từ kết quả khác) và cần biến nó thành recordset để thao tác ORM.

Tại sao dùng

  • Cực rẻ: không truy vấn cho tới khi cần. Cho phép gom prefetch hiệu quả.
  • Là cách đúng đắn để "hồi sinh" id thành đối tượng ORM thay vì viết SQL.

Ví dụ có giải thích

book = self.env['library.book'].browse(42)     # chưa có SQL nào chạy
print(book.name)                               # ĐẾN ĐÂY mới SELECT từ DB

books = self.env['library.book'].browse([1, 2, 3])   # recordset 3 record

Giải thích: browse(42) chỉ tạo recordset trỏ id 42. Khi đọc book.name, ORM mới SELECT (và prefetch luôn các field/record lân cận để tối ưu).

Cảnh báo: browse không kiểm tra tồn tại. Nếu id không có thật, recordset vẫn tạo được nhưng đọc field sẽ lỗi MissingError. Khi nghi ngờ, dùng kèm exists(): book = self.env['...'].browse(id).exists().


3.2 search

Lý thuyết search(domain, offset=0, limit=None, order=None) tìm và trả về recordset các bản ghi thỏa domain (điều kiện). Đây là method truy vấn cốt lõi nhất.

Khi nào dùng

  • Khi cần lấy các bản ghi theo điều kiện và sẽ thao tác chúng như đối tượng ORM (gọi method, đọc/ghi field, đi theo quan hệ).

Tại sao dùng

  • Trả về recordset đầy đủ năng lực ORM (khác search_read chỉ trả dict).
  • Hỗ trợ phân trang (offset, limit) và sắp xếp (order).
  • Tự động áp record rules (an ninh theo dòng) — chỉ trả record user được phép thấy.

Ví dụ có giải thích

# Domain cơ bản (các điều kiện ngầm AND)
books = self.env['library.book'].search([
    ('state', '=', 'available'),
    ('price', '>', 20),
])

# Toán tử OR (tiền tố): (state = available) OR (price < 5)
books = self.env['library.book'].search(
    ['|', ('state', '=', 'available'), ('price', '<', 5)]
)

# Phân trang + sắp xếp: 10 record, bỏ 20 record đầu, sắp theo giá giảm dần
page = self.env['library.book'].search(
    [], offset=20, limit=10, order='price desc'
)

# Lấy record mới nhất
last = self.env['library.book'].search([], order='create_date desc', limit=1)

Giải thích: '|' đứng trước hai điều kiện mà nó nối (ký pháp Ba Lan). search([]) với domain rỗng nghĩa là "tất cả" (vẫn lọc theo quyền). limit=1 + order là mẫu chuẩn để lấy bản ghi đầu/cuối.

Lưu ý hiệu năng: Nếu chỉ cần đếm, dùng search_count. Nếu chỉ cần đọc một vài field để hiển thị, dùng search_read. Đừng search rồi lặp đọc nếu có cách gọn hơn.


3.3 search_count

Lý thuyết search_count(domain) trả về số nguyên — số bản ghi thỏa domain, không tải dữ liệu record.

Khi nào dùng

  • Khi chỉ cần biết "có bao nhiêu", "có tồn tại record nào không".

Tại sao dùng

  • Nhanh và nhẹ: chạy SELECT COUNT(*) thay vì kéo recordset về rồi len().
  • Tránh lãng phí bộ nhớ và prefetch khi không cần dữ liệu.

Ví dụ có giải thích

n = self.env['library.book'].search_count([('state', '=', 'borrowed')])
# -> ví dụ 17 (int)

# Kiểm tra tồn tại theo cách rẻ
if self.env['library.book'].search_count([('isbn', '=', isbn)], limit=1):
    raise UserError("ISBN đã tồn tại")

Giải thích: So với len(search(domain)) (kéo toàn bộ record về rồi đếm), search_count chỉ chạy COUNT trên DB → rẻ hơn nhiều với tập lớn. Mẫu search_count(domain, limit=1) là cách kiểm tra "tồn tại hay không" hiệu quả (Odoo 17 cho phép limit).


3.4 _name_search

Lý thuyết _name_search(name, args=None, operator='ilike', limit=100, ...) là phần lõi đứng sau cơ chế tìm kiếm của field Many2one (ô gõ để chọn record). Mặc định nó tìm theo field _rec_name/name. Override để tùy biến logic tìm (cho phép tìm theo nhiều field).

Khi nào dùng

  • Khi muốn ô Many2one tìm được record theo nhiều thuộc tính (ví dụ gõ mã hoặc tên đều ra), hoặc lọc theo ngữ cảnh.

Tại sao dùng

  • Trải nghiệm tìm kiếm tốt hơn: người dùng nhập mã ISBN, mã nội bộ, hoặc tên — đều khớp.
  • Là điểm móc chuẩn (thay cho name_get/name_search cũ) trong Odoo 17.

Ví dụ có giải thích

from odoo import api, models

class LibraryBook(models.Model):
    _inherit = 'library.book'

    @api.model
    def _name_search(self, name, domain=None, operator='ilike', limit=None, order=None):
        domain = domain or []
        if name:
            # Cho phép tìm theo TÊN hoặc ISBN cùng lúc
            domain = ['|', ('name', operator, name),
                           ('isbn', operator, name)] + domain
        return self._search(domain, limit=limit, order=order)

Giải thích: Khi người dùng gõ vào ô chọn sách, Odoo gọi _name_search với name là chuỗi gõ. Ta mở rộng domain để khớp name HOẶC isbn. _search(...) (gạch dưới) trả về tập id ở mức thấp mà cơ chế hiển thị cần. Lưu ý chữ ký method ở Odoo 17 dùng domain (không phải args như bản cũ).

Liên quan: display text hiển thị do display_name/_compute_display_name quyết định; _name_search chỉ lo tìm.


3.5 read

Lý thuyết read(fields=None, load='_classic_read') đọc dữ liệu từ một recordset đã có và trả về list các dict (dạng JSON-friendly), mỗi dict ứng một record với các field yêu cầu. Many2one trả về dạng (id, display_name).

Khi nào dùng

  • Khi cần serialize dữ liệu để gửi ra ngoài (RPC, API, JSON), hoặc cần một snapshot dạng dict.

Tại sao dùng

  • Trả về cấu trúc thuần (list/dict) dễ chuyển JSON, thay vì đối tượng recordset.
  • Đọc nhiều field cùng lúc trong một lượt.

Ví dụ có giải thích

books = self.env['library.book'].browse([1, 2])
data = books.read(['name', 'price', 'author_id'])
# data = [
#   {'id': 1, 'name': 'A', 'price': 10.0, 'author_id': (5, 'Tác giả X')},
#   {'id': 2, 'name': 'B', 'price': 20.0, 'author_id': (7, 'Tác giả Y')},
# ]

Giải thích: read cần một recordset có sẵn (browse/search trước). Many2one author_id trả về tuple (id, display_name) — rất tiện cho giao diện. Nếu bỏ trống fields, đọc tất cả field (nặng, nên hạn chế).

So sánh: read thao tác trên recordset đã có; nếu bạn xuất phát từ domain và muốn dict luôn, dùng search_read để gộp một bước.


3.6 search_read

Lý thuyết search_read(domain, fields, offset, limit, order)gộp của search + read: truy vấn theo domain và trả thẳng list dict, không cần tạo recordset trung gian.

Khi nào dùng

  • Khi cần lấy dữ liệu để hiển thị/xuất (bảng, biểu đồ, API) mà không cần thao tác ORM tiếp theo trên các record đó.

Tại sao dùng

  • Hiệu quả hơn search() rồi read(): tối ưu thành ít truy vấn, tránh chi phí dựng recordset/prefetch thừa.
  • Là method rất hay dùng cho frontend/JS-RPC.

Ví dụ có giải thích

rows = self.env['library.book'].search_read(
    domain=[('state', '=', 'available')],
    fields=['name', 'price'],
    order='price desc',
    limit=20,
)
# rows = [{'id': .., 'name': .., 'price': ..}, ...]

Giải thích: Một lệnh trả ra ngay list dict đã lọc + sắp xếp + giới hạn. So với search().read() (hai bước, dựng recordset rồi mới đọc), search_read gọn và nhanh hơn khi đích đến chỉ là dữ liệu thô.

Khi KHÔNG dùng: Nếu sau đó bạn cần gọi method nghiệp vụ trên các record (vd record.action_confirm()), hãy dùng search để giữ recordset.


3.7 read_group

Lý thuyết read_group(domain, fields, groupby, ...) là truy vấn tối ưu cho gom nhóm (GROUP BY) và tổng hợp (sum, count, avg, min, max). Trả về list dict, mỗi dict đại diện một nhóm cùng các giá trị tổng hợp.

Khi nào dùng

  • Khi cần báo cáo/thống kê: tổng doanh thu theo tháng, số sách theo danh mục, giá trung bình theo tác giả...
  • Là động cơ phía sau các view pivot, graph, và nhóm trong list view.

Tại sao dùng

  • Đẩy phép tổng hợp xuống tận DB (PostgreSQL) → nhanh hơn rất nhiều so với kéo hết record về Python rồi tự cộng.
  • Hỗ trợ gom theo field thời gian (theo ngày/tháng/năm qua :month).

Ví dụ có giải thích

# Tổng và đếm giá sách theo từng danh mục
result = self.env['library.book'].read_group(
    domain=[('state', '=', 'available')],
    fields=['price:sum', 'price:avg'],   # hàm tổng hợp trên field price
    groupby=['category_id'],
)
# result = [
#   {'category_id': (1, 'Văn học'), 'price': 320.0, 'price_avg': 40.0,
#    'category_id_count': 8, '__domain': [...]},
#   ...
# ]

# Gom theo tháng của ngày xuất bản
by_month = self.env['library.book'].read_group(
    domain=[],
    fields=['price:sum'],
    groupby=['publish_date:month'],
)

Giải thích: Cú pháp '<field>:<aggregate>' (vd price:sum) yêu cầu DB tính tổng. Khóa <groupby>_count cho biết số record mỗi nhóm. __domain của mỗi nhóm rất hữu ích: bấm vào nhóm trên UI để "drill-down" xuống đúng các record đó. Hậu tố :month gom theo tháng cho field ngày.

Lưu ý: Field trong fields để tổng hợp phải là kiểu số (hoặc dùng đếm). Trong Odoo 17, read_group vẫn là API ổn định cho aggregation phía server.


3.8 fetch (mới từ Odoo 17)

Lý thuyết fetch(field_names) là method mới giới thiệu từ Odoo 17, dùng để nạp trước (load) giá trị các field vào cache cho recordset hiện tại — nhưng không trả về list dict như read. Nó trả về chính recordset (đã có dữ liệu trong cache) / không trả dữ liệu dạng serialize.

Khi nào dùng

  • Khi muốn chủ động prefetch một nhóm field cụ thể vào cache trước khi xử lý, để tránh các truy vấn lẻ tẻ về sau.
  • Khi cần dữ liệu trong Python để tính toán (chứ không phải để serialize ra ngoài như read).

Tại sao dùng

  • read tạo ra list dict (tốn công serialize, đặc biệt format Many2one thành tuple). Nếu bạn chỉ cần giá trị nằm sẵn trong recordset để truy cập field bình thường, fetch nhẹ và đúng mục đích hơn.
  • Kiểm soát rõ ràng thời điểm nạp dữ liệu (tối ưu vòng lặp lớn).

Ví dụ có giải thích

books = self.env['library.book'].search([('state', '=', 'available')])

# Nạp sẵn 'price' và 'page_count' cho toàn bộ tập trong 1 truy vấn
books.fetch(['price', 'page_count'])

# Từ đây truy cập các field này KHÔNG bắn thêm SQL (đã ở cache)
total = sum(b.price for b in books)

Giải thích: Sau fetch(['price', 'page_count']), ORM đã SELECT các cột này cho mọi record một lượt. Vòng lặp đọc b.price chạy hoàn toàn trên cache. Khác read ở chỗ ta vẫn làm việc với recordset (b.price) chứ không phải dict.

Phân biệt nhanh:

  • readtrả về list dict để xuất ra ngoài.
  • fetchnạp vào cache để dùng tiếp trong Python với cú pháp recordset.

Nhóm 4 — Metadata & Kiểm tra

4.1 fields_get

Lý thuyết fields_get(allfields=None, attributes=None) trả về metadata của các field: kiểu dữ liệu (type), nhãn (string), bắt buộc (required), chỉ đọc (readonly), lựa chọn (selection), model quan hệ (relation)...

Khi nào dùng

  • Khi viết code động/đa hình cần biết cấu trúc field: tự sinh form, export, validate, công cụ tích hợp.
  • Khi UI (web client) cần mô tả field để render.

Tại sao dùng

  • Cho phép viết logic không phụ thuộc cứng vào model cụ thể (introspection).
  • Lấy được nhãn/selection để hiển thị thân thiện.

Ví dụ có giải thích

meta = self.env['library.book'].fields_get(['state', 'author_id'])
# meta = {
#   'state': {'type': 'selection', 'string': 'Trạng thái',
#             'selection': [('draft','Nháp'), ('available','Sẵn có')], ...},
#   'author_id': {'type': 'many2one', 'string': 'Tác giả',
#                 'relation': 'res.partner', ...},
# }

# Chỉ lấy vài thuộc tính để nhẹ
labels = self.env['library.book'].fields_get(attributes=['string', 'type'])

Giải thích: Với author_id ta biết được relation='res.partner' để biết nó trỏ tới model nào. Với state ta lấy được danh sách selection để map giá trị → nhãn hiển thị. Lọc attributes giúp giảm khối lượng dữ liệu trả về.


4.2 exists

Lý thuyết exists() lọc recordset, chỉ giữ lại những record thực sự còn tồn tại trong DB. Trả về recordset (có thể rỗng).

Khi nào dùng

  • Khi recordset có thể chứa id đã bị xóa (do người khác xóa, do browse id không chắc chắn, do thao tác bất đồng bộ).
  • Như một "trạm kiểm tra" trước khi thao tác để tránh MissingError.

Tại sao dùng

  • Ngăn lỗi khi truy cập record đã biến mất.
  • Phổ biến trong các method chạy nền/queue nơi record có thể đã bị xóa giữa chừng.

Ví dụ có giải thích

book = self.env['library.book'].browse(book_id).exists()
if not book:
    return   # id không còn tồn tại -> thoát an toàn
book.action_borrow()

# Lọc bỏ các record đã bị xóa khỏi một tập
valid_books = books.exists()

Giải thích: browse(book_id) không kiểm tra tồn tại; nối .exists() sẽ chạy truy vấn nhẹ và trả về recordset rỗng nếu id không còn. Nhờ đó if not book xử lý được trường hợp đã bị xóa mà không nổ lỗi.


4.3 ensure_one

Lý thuyết ensure_one() bắt buộc recordset hiện tại phải có đúng 1 record. Nếu có 0 hoặc nhiều hơn 1 → ném ValueError (Singleton Error). Trả về chính recordset đó.

Khi nào dùng

  • Ở đầu các method/logic chỉ có ý nghĩa với một record (truy cập trực tiếp self.field, mở wizard cho 1 đối tượng...).

Tại sao dùng

  • Truy cập self.price trên recordset nhiều record sẽ gây lỗi/khó lường. ensure_one() biến lỗi mơ hồ thành thông báo rõ ràng, sớm.
  • Bảo vệ giả định "đơn lẻ" của code.

Ví dụ có giải thích

def action_print_label(self):
    self.ensure_one()         # đảm bảo chỉ 1 sách được chọn
    return self._render_label(self.name, self.isbn)

Giải thích: Khi nút được bấm trên đúng 1 form, self có 1 record và ensure_one() qua êm. Nếu method bị gọi nhầm trên nhiều record (vd hành động hàng loạt), nó báo lỗi ngay thay vì lặng lẽ chỉ lấy record đầu — giúp lộ bug sớm.


4.4 get_metadata

Lý thuyết get_metadata() trả về metadata cốt lõi của (các) record: người tạo (create_uid), ngày tạo (create_date), người sửa cuối (write_uid), ngày sửa cuối (write_date), và XML ID (xmlid) cùng thông tin liên quan. Trả về list dict (một dict mỗi record).

Khi nào dùng

  • Khi cần thông tin audit/truy vết của bản ghi (ai tạo, khi nào, ai sửa).
  • Khi cần biết XML ID của record (rất hữu ích để export data, viết migration, hoặc tham chiếu trong code).

Tại sao dùng

  • Tập trung lấy nhanh thông tin "lý lịch" record mà UI hay hiển thị ở nút "i" (info).

Ví dụ có giải thích

info = book.get_metadata()
# info = [{
#   'id': 42,
#   'create_uid': (2, 'Admin'),
#   'create_date': '2026-01-10 08:00:00',
#   'write_uid': (5, 'Thủ thư A'),
#   'write_date': '2026-05-30 14:22:00',
#   'xmlid': 'library_data.book_clean_code',   # nếu có external id
#   'noupdate': False,
# }]

Giải thích: xmlid cho biết external identifier nếu record được nạp từ data file — cực kỳ quan trọng khi viết module/migration để tham chiếu ổn định. Các field create_*/write_* cho biết lịch sử chỉnh sửa cơ bản.


Nhóm 5 — Xử lý recordset trong bộ nhớ

Điểm chung quan trọng: Cả 4 method dưới (filtered, mapped, sorted, grouped) hoạt động trên recordset đã tải, dùng cache, không bắn thêm SQL (miễn là field đã có trong cache). Đây là lý do chúng giúp tối ưu: thay vì gọi search nhiều lần, ta tải một lần rồi xử lý trong RAM.

5.1 filtered

Lý thuyết filtered(func) trả về recordset con gồm các record thỏa điều kiện. func có thể là một hàm/lambda trả về bool, hoặc một chuỗi tên field (lọc theo tính "truthy" của field, hỗ trợ đi sâu quan hệ bằng dấu chấm).

Khi nào dùng

  • Khi đã có sẵn recordset và muốn lọc tiếp theo điều kiện mà không quay lại DB.

Tại sao dùng

  • Tránh search thừa: nếu dữ liệu đã nằm trong tay, lọc trong bộ nhớ nhanh hơn và đỡ tải DB.
  • Cú pháp gọn, đọc dễ.

Ví dụ có giải thích

books = self.env['library.book'].search([])   # tải 1 lần

# Lọc bằng lambda
expensive = books.filtered(lambda b: b.price > 50)

# Lọc theo chuỗi field (giữ record có active = True)
active_books = books.filtered('active')

# Đi sâu quan hệ: giữ sách mà tác giả đang ở Việt Nam
vn_books = books.filtered(lambda b: b.author_id.country_id.code == 'VN')

Giải thích: filtered('active') tương đương filtered(lambda b: b.active). Vì books đã tải, các phép lọc trên chạy trong RAM. Nếu thay bằng nhiều lệnh search khác nhau trên cùng tập gốc, ta sẽ bắn thêm nhiều SQL không cần thiết.

Khi nên dùng search thay vì filtered: Nếu tập gốc rất lớn và bạn chỉ cần một phần nhỏ, search với domain (lọc tại DB) lại tốt hơn vì không kéo cả tập về. filtered tối ưu khi bạn đã cần/đã có recordset đó.


5.2 mapped

Lý thuyết mapped(func) duyệt recordset và trích xuất giá trị một field (hoặc kết quả một hàm), gom lại trả về. Với field thường → trả về list Python. Với field quan hệ → trả về recordset (đã tự gộp, loại trùng theo thứ tự).

Khi nào dùng

  • Khi cần "rút" một cột giá trị ra khỏi recordset, hoặc "nhảy" qua quan hệ để gom tập record liên quan.

Tại sao dùng

  • Viết gọn thay vì for thủ công.
  • mapped trên field quan hệ trả về recordset đã hợp nhất — rất tiện để thao tác tiếp.

Ví dụ có giải thích

books = self.env['library.book'].search([])

prices = books.mapped('price')          # -> [10.0, 20.0, ...] (list float)
names = books.mapped('name')            # -> ['A', 'B', ...]   (list str)

# Field quan hệ -> recordset gộp, KHÔNG trùng
authors = books.mapped('author_id')     # recordset res.partner duy nhất

# Dùng hàm để biến đổi
labels = books.mapped(lambda b: f"{b.name} ({b.price}đ)")

# Đi sâu nhiều cấp
countries = books.mapped('author_id.country_id')

Giải thích: books.mapped('author_id') gom toàn bộ tác giả của các sách thành một recordset không trùng — sau đó có thể authors.mapped('email') để lấy mọi email. Đây là sức mạnh "đi xuyên quan hệ" của mapped. Với field thường, kết quả là list để dễ sum(prices), max(prices)...


5.3 sorted

Lý thuyết sorted(key=None, reverse=False) trả về recordset đã sắp xếp (bản mới), sắp theo key (tên field dạng chuỗi hoặc hàm). reverse=True để giảm dần. Sắp xếp diễn ra trong bộ nhớ.

Khi nào dùng

  • Khi cần sắp xếp một recordset đã có theo tiêu chí (có thể là tiêu chí tính toán không nằm trong DB).

Tại sao dùng

  • Linh hoạt hơn order của search ở chỗ có thể sort theo hàm/biểu thức phức tạp.
  • Tránh round-trip DB khi đã có dữ liệu.

Ví dụ có giải thích

books = self.env['library.book'].search([])

# Sắp theo field (chuỗi)
by_price = books.sorted('price')                 # tăng dần
by_price_desc = books.sorted('price', reverse=True)

# Sắp theo hàm phức tạp: theo độ dài tên rồi theo giá
custom = books.sorted(key=lambda b: (len(b.name), b.price))

Giải thích: sorted('price') đơn giản và đủ cho phần lớn nhu cầu. Khi tiêu chí phức tạp (tuple nhiều khóa, giá trị suy diễn), dùng key=lambda. Lưu ý: với tập rất lớn, sắp xếp tại DB qua search(order=...) thường hiệu quả hơn sorted trong RAM.


5.4 grouped (mới từ Odoo 17)

Lý thuyết grouped(key) là tiện ích mới từ Odoo 17, gom recordset đang ở trong bộ nhớ theo một key (tên field dạng chuỗi hoặc hàm) và trả về dict: khóa là giá trị nhóm, value là recordset con thuộc nhóm đó.

Lưu ý phân biệt với read_group: read_group gom nhóm ở tầng DB và trả về aggregation (sum/count...). grouped gom trong RAM trên recordset có sẵn và trả về recordset theo nhóm (không tự tổng hợp).

Khi nào dùng

  • Khi đã có recordset và muốn tổ chức lại thành các nhóm để xử lý từng nhóm bằng Python (không cần truy vấn lại).

Tại sao dùng

  • Trước Odoo 17 phải tự viết vòng lặp dồn vào dict; grouped làm chuẩn hóa, gọn gàng.
  • Giữ value là recordset → tiếp tục dùng được mọi method ORM (mapped, filtered, write...).

Ví dụ có giải thích

books = self.env['library.book'].search([])

# Gom theo field (chuỗi) -> dict {recordset_key: recordset_books}
by_category = books.grouped('category_id')
for category, group in by_category.items():
    total = sum(group.mapped('price'))
    print(category.display_name, '->', len(group), 'cuốn, tổng', total)

# Gom theo hàm tự định nghĩa
by_band = books.grouped(lambda b: 'đắt' if b.price > 50 else 'rẻ')

Giải thích: Với grouped('category_id'), khóa của dict là record danh mục (recordset Many2one), value là recordset các sách thuộc danh mục đó — nên ngay trong vòng lặp ta group.mapped('price') để tính tổng. Vì mọi thứ chạy trên cache, không có thêm SQL. Khi cần tổng hợp số liệu lớn hãy cân nhắc read_group (DB tính nhanh hơn); khi cần thao tác tiếp trên record thì grouped tiện hơn.


Nhóm 6 — View

6.1 get_view (thay thế fields_view_get)

Lý thuyết get_view(view_id=None, view_type='form', **options) trả về kiến trúc (architecture) của một view dưới dạng dữ liệu mà web client dùng để render: XML arch, danh sách model, các model con... Trong Odoo 17, get_view thay thế fields_view_get của các phiên bản cũ. Override để tùy biến XML view động từ Python backend.

Khi nào dùng

  • Khi cần thay đổi cấu trúc view tùy theo ngữ cảnh/người dùng/dữ liệu mà không thể làm bằng XML tĩnh hay thuộc tính attrs/invisible.
  • Ví dụ: thêm cột động, ẩn/hiện nhóm theo công ty, chèn nút phụ thuộc cấu hình.

Tại sao dùng

  • Cho phép can thiệp cây XML trước khi gửi cho client → linh hoạt tối đa cho UI động.
  • Là điểm móc chuẩn (đã đổi tên so với fields_view_get) cần dùng đúng để tương thích Odoo 17.

Ví dụ có giải thích

from lxml import etree
from odoo import api, models

class LibraryBook(models.Model):
    _inherit = 'library.book'

    @api.model
    def get_view(self, view_id=None, view_type='form', **options):
        res = super().get_view(view_id=view_id, view_type=view_type, **options)
        if view_type == 'form':
            doc = etree.XML(res['arch'])        # parse XML hiện có
            # Ví dụ: đặt field 'isbn' thành readonly cho người không thuộc nhóm thủ thư
            if not self.env.user.has_group('library.group_librarian'):
                for node in doc.xpath("//field[@name='isbn']"):
                    node.set('readonly', '1')
            res['arch'] = etree.tostring(doc, encoding='unicode')  # ghi lại
        return res

Giải thích: Ta gọi super().get_view() để lấy arch gốc, parse bằng lxml, dùng xpath để tìm node field name="isbn" và set readonly động theo quyền, rồi serialize ngược lại res['arch']. Đây chính là cách "sửa XML view từ Python". Nếu bạn từng thấy code cũ override fields_view_get, ở Odoo 17 hãy chuyển sang get_view.

Lưu ý: Phần lớn nhu cầu ẩn/hiện đơn giản nên làm bằng XML (invisible, groups, attrs); chỉ dùng get_view khi thực sự cần biến đổi cấu trúc động.


Bảng tổng hợp nhanh

MethodNhómTrả vềCó bắn SQL?Mục đích một dòng
createCRUDrecordset mớiCó (INSERT)Tạo bản ghi mới
writeCRUDTrueCó (UPDATE)Cập nhật bản ghi đã có
unlinkCRUDTrueCó (DELETE)Xóa vĩnh viễn
copyCRUDrecordset mớiCó (INSERT)Nhân bản bản ghi
default_getTạodictThường khôngLấy giá trị mặc định khi tạo
name_createTạo(id, name)Có (INSERT)Quick-create từ một chuỗi
browseĐọcrecordsetKhông (lazy)id → recordset
searchĐọcrecordsetCó (SELECT id)Tìm record theo domain
search_countĐọcintCó (COUNT)Đếm record theo domain
_name_searchĐọcids/recordsetTùy biến tìm cho Many2one
readĐọclist[dict]Có (nếu chưa cache)Serialize recordset → dict
search_readĐọclist[dict]search + read gộp
read_groupĐọclist[dict]Có (GROUP BY)Gom nhóm + tổng hợp tại DB
fetchĐọcrecordsetCó (SELECT)Nạp field vào cache (Odoo 17+)
fields_getMetadictKhôngMetadata các field
existsMetarecordsetCó (nhẹ)Lọc record còn tồn tại
ensure_oneMetarecordsetKhôngBắt buộc đúng 1 record
get_metadataMetalist[dict]Audit info + XML ID
filteredIn-memoryrecordsetKhông*Lọc trên cache
mappedIn-memorylist/recordsetKhông*Trích field / xuyên quan hệ
sortedIn-memoryrecordsetKhông*Sắp xếp trong RAM
groupedIn-memorydictKhông*Gom nhóm trong RAM (Odoo 17+)
get_viewViewdict (arch)Lấy/sửa kiến trúc view động

*Không bắn SQL với điều kiện các field cần dùng đã có trong cache; nếu chưa, ORM sẽ nạp lazy.


Best practices chung

  1. Chọn đúng method theo mục đích đầu ra:

    • Cần thao tác ORM tiếpsearch/browse (giữ recordset).
    • Cần dữ liệu thô để xuất/JSsearch_read/read.
    • Cần con số tổng hợpread_group (hoặc search_count để đếm).
    • Cần giá trị trong Python để tínhfetch rồi đọc field.
  2. Tránh truy vấn trong vòng lặp (N+1): Đừng search/create/write từng record trong for. Hãy gom: một search rồi filtered/mapped, một create([...]) batch, một recordset.write({...}).

  3. Ưu tiên xử lý trên cache khi đã có recordset: filtered/mapped/sorted/grouped rẻ hơn nhiều lệnh search lặp lại — nhưng nếu tập gốc khổng lồ và chỉ cần phần nhỏ, lọc tại DB bằng domain vẫn tốt hơn.

  4. Phòng thủ với exists()ensure_one(): Dùng exists() khi id có thể đã bị xóa (đặc biệt job nền); dùng ensure_one() mở đầu các method "đơn lẻ".

  5. Tôn trọng decorator khi override: create@api.model_create_multi (nhận list); default_get/name_create/_name_search@api.model. Luôn gọi super() để giữ hành vi lõi.

  6. Dùng Command cho field quan hệ trong create/write thay cho cú pháp tuple cũ (0, 0, {...}) để code rõ ràng hơn ở Odoo 17.

  7. Cập nhật tên method theo phiên bản: Ở Odoo 17 dùng get_view (không phải fields_view_get), tận dụng fetchgrouped mới; tránh các API đã loại bỏ như name_get (thay bằng _compute_display_name).