feat: add server-side guard to prevent accidental release of occupied restaurant tables in multi-device POS setups

This commit is contained in:
Suherdy Yacob 2026-06-03 21:17:44 +07:00
parent ffb9d3feaf
commit f8c59c560f
4 changed files with 194 additions and 0 deletions

View File

@ -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 ## Installation
1. Copy the `pos_ui_optimization` folder into your `customaddons` directory 1. Copy the `pos_ui_optimization` folder into your `customaddons` directory
@ -149,6 +184,8 @@ pos_ui_optimization/
│ │ │ ├── payment_screen/ │ │ │ ├── payment_screen/
│ │ │ │ └── payment_screen_patch.xml # Hide Invoice button patch │ │ │ │ └── payment_screen_patch.xml # Hide Invoice button patch
│ │ │ └── product_screen/ │ │ │ └── product_screen/
│ │ │ ├── order_summary/
│ │ │ │ └── safe_release_patch.js # Multi-device Release guard
│ │ │ ├── portrait_mode_patch.js # Tab state (products/cart/numpad) │ │ │ ├── portrait_mode_patch.js # Tab state (products/cart/numpad)
│ │ │ ├── portrait_screen.xml # Portrait layout XML patch │ │ │ ├── portrait_screen.xml # Portrait layout XML patch
│ │ │ ├── product_screen_patch.js # Incremental product rendering │ │ │ ├── product_screen_patch.js # Incremental product rendering

View File

@ -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/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.js',
'pos_ui_optimization/static/src/app/screens/product_screen/order_summary_patch.xml', '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 # Payment screen patches
'pos_ui_optimization/static/src/app/screens/payment_screen/payment_screen_patch.xml', 'pos_ui_optimization/static/src/app/screens/payment_screen/payment_screen_patch.xml',
# Portrait mode — service must load first # Portrait mode — service must load first

View File

@ -4,6 +4,7 @@ from odoo.osv import expression
from datetime import timedelta, datetime from datetime import timedelta, datetime
import pytz import pytz
class PosOrder(models.Model): class PosOrder(models.Model):
_inherit = 'pos.order' _inherit = 'pos.order'
@ -26,3 +27,49 @@ class PosOrder(models.Model):
domain = expression.AND([domain, [('date_order', '>=', yesterday_start_utc)]]) domain = expression.AND([domain, [('date_order', '>=', yesterday_start_utc)]])
return super(PosOrder, self).search_paid_order_ids(config_id, domain, limit, offset) 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),
}

View File

@ -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);
},
});