From 932db6e5f7c9b1d408dd2ed5c9fadb2e78ad307a Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Fri, 19 Jun 2026 10:57:51 +0700 Subject: [PATCH] feat: implement transaction-level session locking in pos_order and idempotent payment creation in pos_payment --- models/__init__.py | 1 + models/pos_order.py | 27 ++++++++++++++++++++++ models/pos_payment.py | 53 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 models/pos_payment.py diff --git a/models/__init__.py b/models/__init__.py index 6de9115..6c2a2fe 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -2,4 +2,5 @@ from . import pos_config from . import res_config_settings from . import pos_order +from . import pos_payment diff --git a/models/pos_order.py b/models/pos_order.py index 3c68c3a..0c4ce75 100644 --- a/models/pos_order.py +++ b/models/pos_order.py @@ -106,7 +106,34 @@ class PosOrder(models.Model): Override to prevent concurrent processing of the same order uuid. Uses pg_advisory_xact_lock on the order's uuid to force subsequent duplicate requests to wait until the first request completes/commits. + Also, locks the session associated with the incoming orders at the database + transaction level to serialize updates affecting the same session and prevent + serialization failures (concurrent updates on pos.session). """ + # Extract unique session IDs from the orders list + session_ids = set() + for order in orders: + session_id = order.get('session_id') + if session_id: + session_ids.add(session_id) + + if session_ids: + # Also find if any of these sessions are closed/closing, and if so, their potential open rescue/config sessions + sessions = self.env['pos.session'].browse(session_ids).exists() + closed_or_closing = sessions.filtered(lambda s: s.state in ('closing_control', 'closed')) + if closed_or_closing: + # Find open sessions for those configs + open_sessions = self.env['pos.session'].search([ + ('state', '=', 'opened'), + ('config_id', 'in', closed_or_closing.mapped('config_id').ids) + ]) + session_ids.update(open_sessions.ids) + + # De-duplicate, filter to existing, sort to prevent deadlocks, and lock using FOR UPDATE + resolved_session_ids = sorted(list(session_ids)) + if resolved_session_ids: + self.env.cr.execute("SELECT id FROM pos_session WHERE id IN %s FOR UPDATE", (tuple(resolved_session_ids),)) + # Sort orders by uuid to prevent potential deadlocks when locking multiple orders sorted_orders = sorted(orders, key=lambda x: x.get('uuid') or '') for order in sorted_orders: diff --git a/models/pos_payment.py b/models/pos_payment.py new file mode 100644 index 0000000..56b36c3 --- /dev/null +++ b/models/pos_payment.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +from odoo import models, api + +class PosPayment(models.Model): + _inherit = 'pos.payment' + + @api.model_create_multi + def create(self, vals_list): + # 1. Extract and sort unique uuids to prevent deadlocks + uuids = [] + for vals in vals_list: + if isinstance(vals, dict) and vals.get('uuid'): + uuids.append(vals['uuid']) + + sorted_uuids = sorted(list(set(uuids))) + + # 2. Acquire transaction-level PostgreSQL advisory locks on the sorted hashes of uuids + for uuid in sorted_uuids: + self.env.cr.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (uuid,)) + + # 3. Check which payments already exist in the database + existing_payments_map = {} + if sorted_uuids: + existing_payments = self.search([('uuid', 'in', sorted_uuids)]) + for payment in existing_payments: + existing_payments_map[payment.uuid] = payment + + # 4. Prepare values to create (only those that don't already exist) + final_payments_list = [None] * len(vals_list) + vals_to_create = [] + create_indices = [] + + for i, vals in enumerate(vals_list): + uuid = vals.get('uuid') if isinstance(vals, dict) else None + if uuid and uuid in existing_payments_map: + final_payments_list[i] = existing_payments_map[uuid] + else: + vals_to_create.append(vals) + create_indices.append(i) + + # 5. Create new payments + if vals_to_create: + created_payments = super(PosPayment, self).create(vals_to_create) + for idx, payment in zip(create_indices, created_payments): + final_payments_list[idx] = payment + + # 6. Reconstruct and return the final combined recordset + final_payments = self.env['pos.payment'] + for payment in final_payments_list: + if payment: + final_payments += payment + + return final_payments