Một Recovery Session trên dashboard POS không phải cùng vấn đề với thông báo Connection Lost trên trình duyệt. Điều gì kích hoạt mỗi bên, và Odoo 18 đã đổi luật chơi thế nào.

Một quản lý cửa hàng nhắn cho chúng tôi giữa trưa thứ Bảy: “Trên dashboard có một Recovery Session, giờ tôi phải làm gì?” Chuyên viên trực mất hai mươi phút soi log mạng rồi mới nhận ra kết nối của cửa hàng chẳng có vấn đề gì. Vấn đề nằm ở một session đã đóng trên máy chủ, không phải ở việc trình duyệt rớt mạng. Cùng một triệu chứng, cách sửa hoàn toàn khác nhau.

Bài này giải thích Recovery Session của POS trong Odoo là gì, nó hoạt động ra sao trên Odoo 16, 17 và 18, và cách xử lý. Bài cũng nói riêng về chế độ offline, vì gộp hai thứ đó làm một là cách chắc chắn nhất để phí thời gian vào một chẩn đoán sai.

Recovery session và chế độ offline

Đây là hai cơ chế tách biệt. Chúng có điều kiện kích hoạt khác nhau, triệu chứng khác nhau và cách xử lý khác nhau.

Chế độ offline (phía trình duyệt)

Chế độ offline là một trạng thái của trình duyệt. Nó được kích hoạt khi trình duyệt phát ra sự kiện offline gốc, vốn xảy ra ở tầng mạng của hệ điều hành, không phải do một request HTTP thất bại.

Khi trình duyệt phát ra offline, phần frontend của POS bật một cờ nội bộ (network.offline = true). Mọi lời gọi RPC sau đó lập tức ném ra ConnectionLostError trước khi có bất kỳ request mạng nào được gửi đi. Thu ngân thấy hộp thoại “Connection Lost” một lần, rồi một biểu tượng đồng bộ xuất hiện trên thanh điều hướng của POS. Các thao tác không thiết yếu được xếp hàng chờ thử lại.

Khi mạng trở lại, trình duyệt phát ra online. Frontend tự động gọi syncData() và đẩy hết hàng đợi đi. Thu ngân vẫn có thể tiếp tục nhận đơn ở máy cục bộ trong lúc offline. Ở Odoo 16 và 17, các đơn nháp được giữ trong bộ nhớ trình duyệt và localStorage. Ở Odoo 18, chúng được sao song song sang IndexedDB và sống sót qua một lần tải lại trang trọn vẹn.

Recovery session (phía máy chủ)

Một recovery session (bên trong mã nguồn gọi là rescue session) là một bản ghi pos.session có rescue = True. Đó là một lưới an toàn phía máy chủ, được tạo ra khi có đơn hàng đẩy lên một session vốn đã đóng hoặc đang đóng.

Thông thường, ngay lúc đó thu ngân không thấy gì bất thường trên frontend của POS. Dấu vết xuất hiện muộn hơn: dashboard POS ở backend hiện một bộ đếm “Recovery Sessions” trên thẻ cấu hình POS tương ứng.

Recovery session được xử lý từ dashboard ở backend. Không thể đóng chúng từ giao diện POS.

Câu hỏi để chẩn đoán

Khi một cửa hàng báo có vấn đề, hãy hỏi một câu trước tiên: “POS đang hiện thông báo ‘Connection Lost’ trên trình duyệt, hay anh/chị đang nhìn vào dashboard ở backend?”

Hộp thoại Connection Lost trên trình duyệt: chế độ offline. Hãy kiểm tra kết nối mạng, chờ tự đồng bộ.

Bộ đếm Recovery Session trên dashboard backend: rescue session. Hãy làm theo các bước xử lý ở mục 4.

Điều gì tạo ra một recovery session

Nguyên nhân chính là một đơn hàng bị đẩy lên một session đang ở trạng thái closing_control hoặc closed trên máy chủ. Kịch bản phổ biến nhất:

  1. Máy A và máy B cùng nằm trong một session POS.
  2. Người dùng máy A đóng session từ giao diện POS.
  3. Máy B vẫn còn một đơn chưa xong (thu ngân chưa xác nhận, hoặc lời gọi RPC xác nhận đang trên đường đi lúc session đóng).
  4. Máy B cố đẩy đơn hàng lên máy chủ.

Chuyện xảy ra tiếp theo tùy thuộc phiên bản Odoo.

v16 và v17: âm thầm tạo rescue session

Ở Odoo 16 và 17, phương thức _get_valid_session() của máy chủ tự tạo một pos.session mới với rescue = True khi phát hiện session gốc đã đóng. Session mới được đặt tên (RESCUE FOR Shop/00003) (lấy theo tên session gốc). Đơn hàng được lưu vào session mới này. Máy B nhận phản hồi thành công và thu ngân không thấy lỗi gì.

# pos_order.py:84-114 (v17, giống hệt ở v16)
def _get_valid_session(self, order):
    PosSession = self.env['pos.session']
    closed_session = PosSession.browse(order['pos_session_id'])
    rescue_session = PosSession.search([
        ('state', 'not in', ('closed', 'closing_control')),
        ('rescue', '=', True),
        ('config_id', '=', closed_session.config_id.id),
    ], limit=1)
    if rescue_session:
        _logger.warning('reusing recovery session %s for saving order %s', rescue_session.name, order['name'])
        return rescue_session

    _logger.warning('attempting to create recovery session for saving order %s', order['name'])
    new_session = PosSession.create({
        'config_id': closed_session.config_id.id,
        'name': _('(RESCUE FOR %(session)s)', session=closed_session.name),
        'rescue': True,  # avoid conflict with live sessions
    })
    new_session.action_pos_session_open()
    return new_session

Rescue session sau đó xuất hiện trên dashboard ở backend, chờ được rà soát và ghi sổ.

v18: không còn tự tạo

Ở Odoo 18, _get_valid_session() được đơn giản hóa. Nó tìm bất kỳ session nào chưa đóng trên cùng cấu hình POS. Nếu tìm thấy (một session đang chạy hoặc một rescue session có từ trước), nó dùng luôn. Nếu không có gì đang mở, nó ném ra UserError:

No open session available. Please open a new session to capture the order.

Đây là một thay đổi hành vi phá vỡ tương thích so với v16/v17. Ở v18, rescue session không tự xuất hiện. Nếu thấy UserError thay vì một rescue session, nghĩa là trên cấu hình POS đó không còn session nào đang mở. Cách sửa là mở một session mới từ dashboard, không phải tải lại trình duyệt.

Lưu ý: kịch bản production chính xác tạo ra một rescue session ở v18 (thay vì ném UserError) đáng được xác nhận lại trên chính môi trường của bạn. Hai khả năng cao nhất là: vẫn còn một session đang chạy trên một máy khác, hoặc một rescue session tạo trước đó chưa được ghi sổ.

Các kịch bản kích hoạt khác

  • Quản trị viên đóng session từ backend Odoo trong lúc một máy đang giữa chừng một giao dịch.
  • Rớt mạng khiến frontend mất đồng bộ, rồi khi kết nối lại thì phát hiện session đã bị người khác đóng trong lúc nó offline.

Ở cả hai trường hợp, khi bất kỳ ai đóng một session, một thông báo websocket (CLOSING_SESSION) được phát tới mọi máy POS đang kết nối. Mỗi máy thử syncAllOrders một lần cuối, rồi tải lại trang và chuyển hướng về dashboard ở backend.

Bên trong nó hoạt động ra sao

Luồng Python (phía máy chủ, v18)

Frontend gọi pos.order.sync_from_ui() (thay cho create_from_ui ở v16/v17). Bên trong _process_order(), máy chủ kiểm tra trạng thái session:

# pos_order.py:80-82 (v18)
pos_session = self.env['pos.session'].browse(order['session_id'])
if pos_session.state == 'closing_control' or pos_session.state == 'closed':
    order['session_id'] = self._get_valid_session(order).id

Nếu session đã đóng hoặc đang đóng, máy chủ lặng lẽ gán lại đơn hàng sang một session khác. Đơn hàng không bị mất.

_get_valid_session() ở v18:

# pos_order.py:36-55 (v18)
open_session = PosSession.search([
    ('state', 'not in', ('closed', 'closing_control')),
    ('config_id', '=', closed_session.config_id.id)
], limit=1)

if open_session:
    return open_session

raise UserError(
    _('No open session available. Please open a new session to capture the order.')
)

Ở v16/v17, chính phương thức này tự tạo một rescue session:

# pos_order.py:84-114 (v17) — khối mã không còn ở v18
new_session = PosSession.create({
    'config_id': closed_session.config_id.id,
    'name': _('(RESCUE FOR %(session)s)', session=closed_session.name),
    'rescue': True,
})
new_session.action_pos_session_open()
return new_session

Ở v18, khối tạo session đó đã biến mất. Nếu không tìm được session nào đang mở, lời gọi thất bại với UserError.

Đổi session ở frontend (v18)

Khi sync_from_ui trả về một bản ghi pos.session (vì đơn hàng đã được chuyển sang một session khác), syncAllOrders() trong store của POS phát hiện điều này và tráo đối tượng session cục bộ:

// pos_store.js:1299-1331 (rút gọn)
if (newSession) {
    // discard old session, reassign all local draft orders to new session
    session.delete();
    draftOrders.forEach(order => order.session_id = this.session);
}

Thu ngân không thấy gián đoạn gì. Đơn kế tiếp tiếp tục trong session mới.

Đặc tính của rescue session

Rescue session có ba đặc tính quan trọng, bất kể phiên bản nào:

Bỏ qua ràng buộc duy nhất: ràng buộc ngăn hai session cùng mở trên một cấu hình POS được lọc theo rescue = False. Một rescue session có thể cùng tồn tại với một session đang chạy.

Không lấy tên theo dãy số: rescue session bỏ qua phần gán ir.sequence. Chúng giữ một tên tĩnh (ví dụ (RESCUE FOR Shop/00003) ở v16/v17) thay vì nhận một số thứ tự mới.

Không đếm tiền đầu ca: số dư đầu kỳ được kế thừa từ session đóng gần nhất. Lúc đóng, số dư tiền mặt kỳ vọng được tự tính từ các dòng thanh toán thực tế ghi nhận trong rescue session.

IndexedDB (chỉ có ở v18)

Odoo 18 đưa vào cơ chế sao song song liên tục sang IndexedDB: đơn nháp, dòng đơn hàng và thanh toán được ghi vào database cục bộ của trình duyệt mỗi 200ms. Lúc khởi động, frontend nạp dữ liệu IndexedDB trước khi máy chủ phản hồi.

Hiệu quả thực tế: một lần trình duyệt sập hay một lần buộc tải lại trang không còn cần tới rescue session nữa. Đơn nháp vốn đã nằm trong IndexedDB và được nạp lại ngay. Điều này loại bỏ hẳn một nhóm kịch bản rescue session từng có ở v16/v17, khi trình duyệt sập giữa chừng một đơn hàng sẽ để lại một đơn mồ côi cần rescue session mới cứu được.

Xử lý một recovery session theo từng bước

Quy trình chuẩn

  1. Mở backend POS. Trên thẻ cấu hình POS, tìm huy hiệu hoặc bộ đếm “Rescue Session”. Nhấp vào để mở form của rescue session.
  2. Rà soát từng đơn trong rescue session. Kiểm tra trạng thái (paid, invoiced hay draft) và xác minh các dòng thanh toán khớp với số tiền thực thu.
  3. Với đơn nào còn ở trạng thái draft: kiểm tra xem tiền đã thực sự thu chưa (ngăn kéo tiền, log của máy thanh toán). Nếu rồi, thêm dòng thanh toán thủ công. Nếu chưa, hủy đơn.
  4. Khi mọi đơn đã ở trạng thái cuối, nhấp “Close” trên rescue session từ backend. Thao tác này ghi sổ toàn bộ đơn hàng sang kế toán.

Không thể đóng rescue session từ frontend POS. Phải xử lý từ dashboard ở backend.

Trường hợp riêng: bật kiểm soát tiền mặt

Nếu cấu hình POS có bật kiểm soát tiền mặt, rescue session hành xử khác với session thường:

  • Bước đếm tiền đầu ca bị bỏ qua hoàn toàn. Không có hộp thoại nào hỏi số dư đầu kỳ.
  • Lúc đóng, số dư cuối kỳ kỳ vọng được tự tính từ các khoản thanh toán tiền mặt thực tế ghi nhận trong rescue session. Người vận hành không phải đếm tay.
  • Việc cần làm: đi thẳng vào rà soát trạng thái đơn hàng và ghi sổ. Đừng chờ đợi hay yêu cầu một lần đếm tiền thủ công.

Trường hợp riêng: rủi ro đơn trùng

v18: mặc định đã an toàn. Đơn hàng được đối chiếu theo UUID ở phía máy chủ. Nếu UUID đó đã tồn tại và đơn không ở trạng thái nháp, lần đồng bộ đó bị bỏ qua trong im lặng. Không sinh ra bản trùng.

v16/v17: phải kiểm tra thủ công. Việc khử trùng dựa trên so khớp chuỗi pos_reference cộng với phỏng đoán _is_the_same_order(). Sau một lần cứu đơn, hãy kiểm tra session xem có đơn đã thanh toán bị trùng không trước khi ghi sổ, nhất là khi lần đồng bộ ban đầu đứt giữa chừng.

Trường hợp riêng: “Session already closed by another user”

Thông báo này xuất hiện khi một máy cố đóng một session vốn đã đóng. Backend trả về {'redirect': True} và frontend tự tải lại về dashboard ở backend.

Bước tiếp theo: kiểm tra dashboard xem có rescue session không (v16/v17), hoặc xác minh xem có session nào đang mở không (v18). Nếu ở v18 mà không thấy rescue session lẫn session đang mở, hãy mở một session mới trước khi nhận thêm đơn.

Tra cứu theo phiên bản

Phương thức frontend dùng để đẩy đơn

  • v16 và v17: create_from_ui
  • v18: sync_from_ui

Tự tạo rescue session

  • v16 và v17: _get_valid_session() tự tạo với tên (RESCUE FOR Shop/00003). Máy B nhận phản hồi thành công.
  • v18: không tự tạo. Máy chủ tìm một session đang mở bất kỳ hoặc ném UserError. Không có rescue session nào xuất hiện trừ khi đã có sẵn một cái đang mở.

Frontend có biết session đã bị tráo không

  • v16 và v17: frontend không có phần xử lý tráo session. Nó không nhận ra là đơn hàng đã bị chuyển sang session khác.
  • v18: syncAllOrders() phát hiện cờ newSession và thay đối tượng session cục bộ. Các đơn nháp được gán lại sang session mới một cách tự động.

Lưu dữ liệu khi offline

  • v16 và v17: đơn hàng lưu trong bộ nhớ trình duyệt và localStorage. Tải lại trang là mất mọi đơn nháp chưa lưu.
  • v18: sao song song liên tục sang IndexedDB. Đơn nháp sống sót qua một lần tải lại trang hoặc một lần trình duyệt sập, không cần rescue session.

Khử trùng đơn hàng

  • v16 và v17: so khớp chuỗi pos_reference cộng phỏng đoán _is_the_same_order().
  • v18: tra theo UUID. Đáng tin hơn và ít khớp nhầm hơn.

Thông báo “Session already closed” cho người dùng

  • v16: lỗi trơ, không nhắc gì tới rescue session hay dashboard.
  • v17: thông báo đầy đủ hơn, có nhắc Rescue Session và hướng người dùng về dashboard. Đây là lần đầu trải nghiệm này xuất hiện.
  • v18: giống v17.

Điểm cần canh khi nâng cấp (v17 lên v18)

Việc bỏ cơ chế tự tạo rescue session là thay đổi hành vi đáng kể nhất. Mọi quy trình từng dựa vào chuyện đơn hàng âm thầm rơi vào một rescue session sau khi session chính đóng, nay sẽ ném ra UserError thay vì vậy. Phản ứng vận hành đúng đắn là bảo đảm luôn có ít nhất một session mở trong lúc đơn hàng còn đang được đẩy lên.

Mặt tích cực: trên các hệ thống v18 ổn định, thực tế có ít rescue session hơn, vì IndexedDB đã loại bỏ kịch bản phải cứu đơn sau khi trình duyệt sập.

Tái hiện kịch bản để kiểm thử

Dùng các cách sau để mô phỏng cả hai cơ chế trong môi trường dev hoặc staging, trước một buổi làm việc với khách hàng hoặc một buổi đào tạo.

Cách 1: DevTools của trình duyệt (chế độ offline)

  1. Mở POS trên Chrome hoặc Firefox.
  2. Mở DevTools. Vào tab Network.
  3. Đặt throttling thành “Offline”.
  4. Thử xác nhận một đơn hàng. Hộp thoại “Connection Lost” xuất hiện sau lời gọi RPC bị chặn đầu tiên.
  5. Đưa throttling về “No throttling” (online). Sự kiện online phát ra, syncAllOrdersDebounced() chạy, và các đơn đang chờ được đồng bộ.

Cách này chỉ kiểm thử nhánh chế độ offline. Nó không tạo ra rescue session.

Cách 2: Trợ giúp trong tour test (tự động)

Module point_of_sale có kèm một tiện ích kiểm thử tại static/tests/tours/utils/offline_util.js, dùng được trong console của trình duyệt hoặc trong các tour Playwright:

setOfflineMode();  // monkey-patches window.fetch to throw ConnectionLostError
// run test steps here
setOnlineMode();   // restores originals

Hãy dùng cách này cho các kiểm thử tự động lặp lại được, hoặc khi cách dùng DevTools không tiện.

Cách 3: Kịch bản rescue session (hai tab trình duyệt)

Cách này tái hiện nhánh rescue session ở v16/v17. Với v18, kết quả tùy thuộc vào việc có session nào khác đang mở hay không.

  1. Mở hai tab trình duyệt cùng đăng nhập vào một session POS.
  2. Ở tab 2: vào phần đóng session từ giao diện POS. Hoàn tất quy trình đóng.
  3. Ở tab 1: thêm hàng vào một đơn và thử xác nhận.
  4. Tab 1 nhận thông báo websocket CLOSING_SESSION, thử đồng bộ lần cuối, rồi tải lại về dashboard ở backend.
  5. Trên v16/v17: kiểm tra dashboard xem có rescue session không. Trên v18: kiểm tra xem đã xảy ra một lần tráo session hay đã ném ra UserError.

Cách 4: Soi IndexedDB (chỉ v18)

Cách này xác nhận rằng đơn nháp sống sót qua một lần tải lại trang mà không cần rescue session.

  1. Mở POS trên Chrome. Tạo một đơn nháp với một hoặc vài mặt hàng.
  2. Mở DevTools. Vào Application, rồi IndexedDB. Tìm database tên config-id_<N>_<token>.
  3. Đơn nháp, các dòng của nó và mọi khoản thanh toán đều được sao sang đó.
  4. Đóng hẳn tab trình duyệt. Mở lại POS.
  5. Đơn nháp được nạp từ IndexedDB trước khi máy chủ phản hồi. Không có rescue session nào được tạo.

Những điểm chính

Ba điều làm thay đổi cách bạn phản ứng với một sự cố rescue session:

Rescue session không phải chế độ offline. Hãy hỏi xem vấn đề là thông báo “Connection Lost” trên trình duyệt hay một huy hiệu trên dashboard backend. Câu trả lời quyết định toàn bộ hướng xử lý.

v18 đã bỏ cơ chế tự tạo. Ở v16/v17, đơn hàng va vào một session đã đóng sẽ âm thầm tạo ra một rescue session. Ở v18, cùng điều kiện đó ném ra UserError. Cách sửa ở v18 là mở (hoặc xác nhận đang có) một session đang mở.

Rescue session đóng từ backend, không phải từ giao diện POS. Rà soát trạng thái đơn, xử lý các khoản thanh toán còn thiếu, ghi sổ. Trên frontend POS không có nút đóng cho rescue session.

Nguồn

[1] point_of_sale/models/pos_session.py:85-88 (v18): định nghĩa trường rescue trên pos.session

[2] point_of_sale/models/pos_session.py:295-302 (v18): ràng buộc duy nhất được lọc theo rescue = False

[3] point_of_sale/models/pos_session.py:377-385 (v18): bỏ qua số dư tiền mặt đầu kỳ với rescue session

[4] point_of_sale/models/pos_session.py:401-412 (v18): tự tính số dư tiền mặt cuối kỳ cho rescue session

[5] point_of_sale/models/pos_session.py:657-676 (v18): _cannot_close_session trả về redirect kèm thông báo về rescue session

[6] point_of_sale/models/pos_order.py:36-55 (v18): _get_valid_session tìm session đang mở hoặc ném UserError

[7] point_of_sale/models/pos_order.py:80-82 (v18): _process_order kiểm tra trạng thái session

[8] point_of_sale/models/pos_order.py:1110-1158 (v18): sync_from_ui, điểm vào chính

[9] point_of_sale/models/pos_order.py:1000-1001 (v18): khử trùng đơn hàng dựa trên UUID

[10] point_of_sale/static/src/app/store/pos_store.js:1299-1331 (v18): phần tráo session trong syncAllOrders

[11] point_of_sale/static/src/app/store/pos_store.js:259-296 (v18): trình xử lý websocket closingSessionNotification

[12] point_of_sale/static/src/app/models/data_service.js:38-66 (v18): trạng thái network và các listener sự kiện online/offline của trình duyệt

[13] point_of_sale/static/src/app/models/data_service.js:336-338 (v18): cắt ngắn lời gọi RPC khi network.offline bằng true

[14] point_of_sale/static/src/app/models/data_service.js:118-188 (v18): syncDataWithIndexedDB, sao song song liên tục

[15] point_of_sale/static/src/app/errors/error_handlers.js:34-49 (v18): hộp thoại offline chỉ hiện một lần

[16] point_of_sale/static/tests/tours/utils/offline_util.js (v18): các hàm trợ giúp kiểm thử setOfflineMode() và setOnlineMode()

[17] point_of_sale/models/pos_order.py:76-111 (v16): _get_valid_session có phần tự tạo rescue session

[18] point_of_sale/models/pos_order.py:84-114 (v17): _get_valid_session có phần tự tạo rescue session — khối PosSession.create(...) đã bị gỡ ở v18

[19] point_of_sale/models/pos_session.py:513-523 (v16): thông báo “session already closed” dạng trơ

[20] point_of_sale/models/pos_session.py:522-541 (v17): lần đầu tiên thông báo đóng session có nhắc tới rescue session