feat: add server-side guard to prevent accidental release of occupied restaurant tables in multi-device POS setups
This commit is contained in:
parent
ffb9d3feaf
commit
f8c59c560f
37
README.md
37
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
|
## 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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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),
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user