From f8c59c560f552525b54d201fab39f41559c2db25 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Wed, 3 Jun 2026 21:17:44 +0700 Subject: [PATCH] feat: add server-side guard to prevent accidental release of occupied restaurant tables in multi-device POS setups --- README.md | 37 ++++++ __manifest__.py | 2 + models/pos_order.py | 47 ++++++++ .../order_summary/safe_release_patch.js | 108 ++++++++++++++++++ 4 files changed, 194 insertions(+) create mode 100644 static/src/app/screens/product_screen/order_summary/safe_release_patch.js diff --git a/README.md b/README.md index f547746..5da806b 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,41 @@ This mirrors Odoo's standard restaurant `swapButton` logic exactly. --- +### 5. Safe Release Table Guard (Multi-Device) + +Prevents an accidental order cancellation when multiple POS devices share the same +login account in a restaurant setup. + +**The Problem** + +When Device X places orders on a table, Device Y sees the table turn purple (occupied). +If Device Y opens that table before the order has fully synced locally, it may see an +empty order and display the **Release table** button. Clicking Release on Device Y +calls ``action_pos_order_cancel`` on the server, permanently cancelling the order +created on Device X. + +**The Fix** + +Before executing the Release action, this feature performs a quick server-side check: + +1. An RPC call to ``pos.order.check_table_has_real_orders(table_id)`` is made. +2. If the server reports real orders with non-zero lines exist on this table: + + - The release is **blocked**. + - An alert dialog informs the user: *"This table has N active order(s) placed from another device."* + - After the user acknowledges, ``deviceSync.readDataFromServer()`` is triggered so the real orders from Device X appear on Device Y. + - The user is sent back to the Floor Screen with the correct table state. + +3. If the server confirms no real orders exist, the Release proceeds as normal. + +**Offline Fallback** + +If the RPC call fails (e.g. device is offline), the guard falls back to the original +``unbookTable()`` behaviour to avoid blocking legitimate releases. + +--- + + ## Installation 1. Copy the `pos_ui_optimization` folder into your `customaddons` directory @@ -149,6 +184,8 @@ pos_ui_optimization/ │ │ │ ├── payment_screen/ │ │ │ │ └── payment_screen_patch.xml # Hide Invoice button patch │ │ │ └── product_screen/ +│ │ │ ├── order_summary/ +│ │ │ │ └── safe_release_patch.js # Multi-device Release guard │ │ │ ├── portrait_mode_patch.js # Tab state (products/cart/numpad) │ │ │ ├── portrait_screen.xml # Portrait layout XML patch │ │ │ ├── product_screen_patch.js # Incremental product rendering diff --git a/__manifest__.py b/__manifest__.py index 3423ade..6d652c9 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -31,6 +31,8 @@ Features 'pos_ui_optimization/static/src/app/screens/product_screen/product_screen_patch.xml', 'pos_ui_optimization/static/src/app/screens/product_screen/order_summary_patch.js', 'pos_ui_optimization/static/src/app/screens/product_screen/order_summary_patch.xml', + # Safe multi-device Release table guard + 'pos_ui_optimization/static/src/app/screens/product_screen/order_summary/safe_release_patch.js', # Payment screen patches 'pos_ui_optimization/static/src/app/screens/payment_screen/payment_screen_patch.xml', # Portrait mode — service must load first diff --git a/models/pos_order.py b/models/pos_order.py index 00725ea..6841452 100644 --- a/models/pos_order.py +++ b/models/pos_order.py @@ -4,6 +4,7 @@ from odoo.osv import expression from datetime import timedelta, datetime import pytz + class PosOrder(models.Model): _inherit = 'pos.order' @@ -26,3 +27,49 @@ class PosOrder(models.Model): domain = expression.AND([domain, [('date_order', '>=', yesterday_start_utc)]]) return super(PosOrder, self).search_paid_order_ids(config_id, domain, limit, offset) + + @api.model + def check_table_has_real_orders(self, table_id, local_order_ids=None): + """ + Check whether a restaurant table has real (non-empty, non-cancelled) orders + on the server, excluding the empty local order IDs known to the calling device. + + This is a safety guard for the multi-device "Release table" scenario: + - Device X creates an order on table T and syncs it to the server. + - Device Y opens table T and sees an empty local order (sync lag). + - Before allowing Device Y to release (cancel) the empty order, we verify + with the server that no real orderlines exist for this table. + + Args: + table_id (int): The restaurant.table ID to check. + local_order_ids (list[int]): Server IDs of orders already known to the + calling device (typically the empty local order). + + Returns: + dict: + - has_real_orders (bool): True if real draft orders with non-zero lines exist. + - order_count (int): Number of matching server orders found. + """ + if local_order_ids is None: + local_order_ids = [] + + # Find draft (non-finalized) orders for this table that have lines + domain = [ + ('table_id', '=', table_id), + ('state', '=', 'draft'), + ('lines', '!=', False), + ] + if local_order_ids: + # Exclude the empty order(s) already known to the calling device so we + # don't false-positive on an order that this device itself created. + domain = expression.AND([domain, [('id', 'not in', local_order_ids)]]) + + orders = self.env['pos.order'].search(domain) + + # Double-check: at least one line with non-zero qty must exist + real_orders = orders.filtered(lambda o: any(line.qty != 0 for line in o.lines)) + + return { + 'has_real_orders': bool(real_orders), + 'order_count': len(real_orders), + } diff --git a/static/src/app/screens/product_screen/order_summary/safe_release_patch.js b/static/src/app/screens/product_screen/order_summary/safe_release_patch.js new file mode 100644 index 0000000..6a5d4b6 --- /dev/null +++ b/static/src/app/screens/product_screen/order_summary/safe_release_patch.js @@ -0,0 +1,108 @@ +/** @odoo-module **/ +/** + * pos_ui_optimization — Safe Release Table Guard + * + * Problem: + * In a multi-device restaurant POS setup (multiple tablets sharing the same + * user account), Device Y may open a table that Device X has already placed + * orders on. Due to sync lag, Device Y sees an empty local order for the table + * and the "Release table" button becomes visible. + * + * If Device Y clicks Release, the standard `unbookTable()` calls + * `deleteOrders([order])` → `action_pos_order_cancel` on the server, which + * cancels the REAL order created by Device X. + * + * Fix: + * Before executing the release, we call the server-side method + * `check_table_has_real_orders(table_id, local_order_ids)`. + * If the server reports real orders exist, we abort the release, show a + * warning dialog, and refresh the local data so Device Y receives the real + * order from Device X. + */ + +import { patch } from "@web/core/utils/patch"; +import { OrderSummary } from "@point_of_sale/app/screens/product_screen/order_summary/order_summary"; +import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; +import { _t } from "@web/core/l10n/translation"; + +patch(OrderSummary.prototype, { + /** + * Safe override of unbookTable (the "Release table" action). + * + * Sequence: + * 1. Collect the current order's server ID (if it has one) so the server + * can exclude it from its search (it's the empty local order). + * 2. Ask the server: "Does this table have real orders with lines?" + * 3a. Yes → warn the user and refresh data. Do NOT delete. + * 3b. No → proceed with the original unbookTable (safe to release). + */ + async unbookTable() { + const order = this.pos.getOrder(); + const table = this.pos.selectedTable; + + // Only apply the guard when we are in restaurant mode with a selected table. + if (!this.pos.config.module_pos_restaurant || !table) { + return super.unbookTable(...arguments); + } + + // Collect server IDs we want to exclude from the server query. + // These are empty orders already known to this device — if they appear + // on the server we don't want to flag them as "real orders from another device". + const localOrderIds = []; + if (order && typeof order.id === "number") { + localOrderIds.push(order.id); + } + + // Block the UI while we check with the server + this.env.services.ui.block(); + let serverResult; + try { + serverResult = await this.pos.data.call( + "pos.order", + "check_table_has_real_orders", + [table.id, localOrderIds] + ); + } catch (error) { + // If the RPC fails (e.g. offline), fall back to the original behaviour. + // This is safer than silently blocking the release when the user is + // genuinely trying to release a truly-empty table while offline. + this.env.services.ui.unblock(); + console.warn( + "[pos_ui_optimization] safe_release: RPC failed, falling back to original unbookTable.", + error + ); + return super.unbookTable(...arguments); + } finally { + this.env.services.ui.unblock(); + } + + if (serverResult && serverResult.has_real_orders) { + // The server has real orders on this table → another device has placed orders. + // Do NOT release. Refresh the local data instead so the user can see them. + this.env.services.dialog.add(AlertDialog, { + title: _t("Table Has Active Orders"), + body: _t( + "This table has %(count)s active order(s) placed from another device. " + + "Your local view will now refresh to show the latest orders.", + { count: serverResult.order_count } + ), + onClose: async () => { + // Pull the fresh data from the server so this device can see + // Device X's real orderlines. + this.env.services.ui.block(); + try { + await this.pos.deviceSync.readDataFromServer(); + // Navigate back to floor so the user can re-open the table with correct data. + this.pos.showDefault(); + } finally { + this.env.services.ui.unblock(); + } + }, + }); + return; // ← abort the release + } + + // Server confirms: no real orders on this table → safe to release normally. + return super.unbookTable(...arguments); + }, +});