[IMP] pos_restaurant: online table booking

Adds the possibility to book a table online from the website.
The booking is then visible in the POS.

When a new table is created in the PoS, a linked resource is created
in the appointment application, which allows the user to add these
resources to appointment_types, authorising visitors to reserve tables
online.

In pos_config, you need to choose an appointment_type via which the
appointments will be retrieved

A websocket has been integrated to retrieve new bookings instantly
from the PoS, which are displayed directly on the tables.

closes odoo/odoo#124850

Related: odoo/enterprise#42489
Signed-off-by: Joseph Caburnay (jcb) <jcb@odoo.com>
This commit is contained in:
David Monnom (moda)
2023-07-18 09:07:06 +02:00
committed by Joseph Caburnay
parent c6ff1034a7
commit b4339b49a0
19 changed files with 262 additions and 136 deletions
+14 -2
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import secrets
from collections import defaultdict
from datetime import timedelta
from itertools import groupby
@@ -33,6 +33,7 @@ class PosSession(models.Model):
required=True,
index=True)
name = fields.Char(string='Session ID', required=True, readonly=True, default='/')
access_token = fields.Char('Security Token', copy=False)
user_id = fields.Many2one(
'res.users', string='Opened By',
required=True,
@@ -1757,13 +1758,24 @@ class PosSession(models.Model):
return taxes
def _ensure_access_token(self):
# Code taken from addons/portal/models/portal_mixin.py
if not self.access_token:
# we use a `write` to force the cache clearing otherwise `return self.access_token` will return False
self.sudo().write({'access_token': secrets.token_hex(16)})
return self.access_token
def _get_bus_channel_name(self):
return f'pos_session-{self.id}-{self._ensure_access_token()}'
def _loader_params_pos_session(self):
self._ensure_access_token()
return {
'search_params': {
'domain': [('id', '=', self.id)],
'fields': [
'id', 'name', 'user_id', 'config_id', 'start_at', 'stop_at', 'sequence_number',
'payment_method_ids', 'state', 'update_stock_at_closing', 'cash_register_balance_start'
'payment_method_ids', 'state', 'update_stock_at_closing', 'cash_register_balance_start', 'access_token'
],
},
}
@@ -13,8 +13,7 @@ export class PosBus {
this.pos = pos;
this.orm = orm;
// initialize bus_service and listen on pos_config-`id` channel
bus_service.addChannel(`pos_config-${pos.config.id}`);
bus_service.addChannel(`pos_session-${pos.pos_session.id}-${pos.pos_session.access_token}`);
bus_service.addEventListener("notification", ({ detail }) => {
for (const message of detail) {
this.dispatch(message);
@@ -15,6 +15,7 @@ class PosConfig(models.Model):
floor_ids = fields.Many2many('restaurant.floor', string='Restaurant Floors', help='The restaurant floors served by this point of sale.')
set_tip_after_payment = fields.Boolean('Set Tip After Payment', help="Adjust the amount authorized by payment terminals to add a tip after the customers left or at the end of the day.")
module_pos_restaurant = fields.Boolean(default=True)
module_pos_restaurant_appointment = fields.Boolean("Table Booking")
def get_tables_order_count_and_printing_changes(self):
self.ensure_one()
+5 -2
View File
@@ -71,8 +71,11 @@ class PosOrder(models.Model):
def send_table_count_notification(self, table_ids):
messages = []
for config in self.env['pos.config'].search([('floor_ids', 'in', table_ids.floor_id.ids)]):
order_count = config.get_tables_order_count_and_printing_changes()
messages.append((f'pos_config-{config.id}', 'TABLE_ORDER_COUNT', order_count))
config_cur_session = config.current_session_id
if config_cur_session:
order_count = config.get_tables_order_count_and_printing_changes()
messages.append((config_cur_session._get_bus_channel_name(), 'TABLE_ORDER_COUNT', order_count))
self.env['bus.bus']._sendmany(messages)
@api.model
+1 -19
View File
@@ -85,31 +85,13 @@ class RestaurantTable(models.Model):
width = fields.Float('Width', default=50, help="The table's width in pixels")
height = fields.Float('Height', default=50, help="The table's height in pixels")
seats = fields.Integer('Seats', default=1, help="The default number of customer served at this table.")
color = fields.Char('Color', help="The table's color, expressed as a valid 'background' CSS property value")
color = fields.Char('Color', help="The table's color, expressed as a valid 'background' CSS property value", default="#35D374")
active = fields.Boolean('Active', default=True, help='If false, the table is deactivated and will not be available in the point of sale')
def are_orders_still_in_draft(self):
draft_orders = self.env['pos.order'].search([('table_id', '=', self.id), ('state', '=', 'draft')])
return len(draft_orders) > 0
@api.model
def create_from_ui(self, table):
""" create or modify a table from the point of sale UI.
table contains the table's fields. If it contains an
id, it will modify the existing table. It then
returns the id of the table.
"""
if table.get('floor_id'):
table['floor_id'] = table['floor_id'][0]
sanitized_table = dict([(key, val) for key, val in table.items() if key in self._fields and val is not None])
table_id = sanitized_table.pop('id', False)
if table_id:
self.browse(table_id).write(sanitized_table)
else:
table_id = self.create(sanitized_table).id
return table_id
@api.ondelete(at_uninstall=False)
def _unlink_except_active_pos_session(self):
confs = self.mapped('floor_id.pos_config_ids').filtered(lambda c: c.module_pos_restaurant)
@@ -11,6 +11,7 @@ class ResConfigSettings(models.TransientModel):
pos_iface_printbill = fields.Boolean(compute='_compute_pos_module_pos_restaurant', store=True, readonly=False)
pos_iface_splitbill = fields.Boolean(compute='_compute_pos_module_pos_restaurant', store=True, readonly=False)
pos_set_tip_after_payment = fields.Boolean(compute='_compute_pos_set_tip_after_payment', store=True, readonly=False)
pos_module_pos_restaurant_appointment = fields.Boolean(related="pos_config_id.module_pos_restaurant_appointment", readonly=False)
@api.depends('pos_module_pos_restaurant', 'pos_config_id')
def _compute_pos_module_pos_restaurant(self):
@@ -6,9 +6,6 @@
<span class="label drag-handle">
<t t-esc="props.table.name" />
</span>
<span class="table-seats">
<t t-esc="props.table.seats" />
</span>
<t t-if="pos.floorPlanStyle != 'kanban'" t-foreach="handles" t-as="handle" t-key="handle">
<span class="table-handle" t-ref="{{handle}}" t-att-style="computeHandleStyle(handle)"/>
</t>
@@ -44,7 +44,8 @@ export class FloorScreen extends Component {
isColorPicker: false,
});
const ui = useState(useService("ui"));
this.pos.floorPlanStyle = ui.isSmall ? "kanban" : "default";
const mode = localStorage.getItem("floorPlanStyle");
this.pos.floorPlanStyle = ui.isSmall || mode == "kanban" ? "kanban" : "default";
this.floorMapRef = useRef("floor-map-ref");
this.addFloorRef = useRef("add-floor-ref");
this.map = useRef("map");
@@ -268,11 +269,26 @@ export class FloorScreen extends Component {
return firstNum.toString();
}
async _save(table) {
const tableCopy = { ...table };
delete tableCopy.floor;
const tableId = await this.orm.call("restaurant.table", "create_from_ui", [tableCopy]);
table.id = tableId;
this.pos.tables_by_id[tableId] = table;
const tableCopy = {
floor_id: table.floor.id,
color: table.color,
height: table.height,
name: table.name,
position_h: table.position_h,
position_v: table.position_v,
seats: table.seats,
shape: table.shape,
width: table.width,
};
if (table.id) {
await this.orm.write("restaurant.table", [table.id], tableCopy);
} else {
const tableId = await this.orm.create("restaurant.table", [tableCopy]);
table.id = tableId[0];
this.pos.tables_by_id[tableId] = table;
}
}
async _renameFloor(floorId, newName) {
await this.orm.call("restaurant.floor", "rename_floor", [floorId, newName]);
@@ -359,7 +375,9 @@ export class FloorScreen extends Component {
}
}
async onSaveTable(table) {
await this._save(table);
if (this.pos.tables_by_id[table.id]) {
await this._save(table);
}
}
async addFloor() {
const { confirmed, payload: newName } = await this.popup.add(TextInputPopup, {
@@ -381,6 +399,9 @@ export class FloorScreen extends Component {
}
async createTable() {
const newTable = await this._createTableHelper();
newTable.skip_changes = 0;
newTable.changes_count = 0;
newTable.order_count = 0;
if (newTable) {
this.state.selectedTableIds = [];
this.state.selectedTableIds.push(newTable.id);
@@ -564,9 +585,7 @@ export class FloorScreen extends Component {
}
}
this.pos.tables_by_id[id].active = false;
await this.orm.call("restaurant.table", "create_from_ui", [
{ active: false, id: id },
]);
this.orm.write("restaurant.table", [id], { active: false });
this.activeFloor.tables = this.activeTables.filter((table) => table.id !== id);
delete this.pos.tables_by_id[id];
} else {
@@ -150,40 +150,43 @@
// Should probably be moved to table.scss but currently the selectors are too generic
.table {
display: flex;
flex-direction: column;
justify-content: center;
position: absolute;
text-align: center;
font-size: 18px;
color: white;
background: rgb(53, 211, 116);
border-radius: 3px;
cursor: pointer;
box-shadow: 0px 3px rgba(0,0,0,0.07);
transition: background-color 300ms ease-in-out;
overflow: hidden;
box-sizing: border-box;
&.selected {
outline: solid rgba(255,255,255,0.3);
cursor: move;
z-index: 50;
overflow: hidden;
}
.table-seats {
margin-left: -12px;
}
.infos {
display: flex;
gap: 1rem;
align-items: center;
justify-content: center;
}
.order-count {
position: absolute;
top: 0;
left: 50%;
background: black;
width: 20px;
margin-top: 1px;
margin-left: -10px;
height: 20px;
line-height: 20px;
border-radius: 10px;
font-size: 16px;
border-radius: 999px;
z-index: 10;
width: 16px;
height: 16px;
line-height: 16px;
font-size: 16px;
position: absolute;
padding: 3px;
&.notify-printing {
background: red;
@@ -195,37 +198,32 @@
}
}
.table-cover {
display: block;
position: absolute;
left: 0; right: 0; bottom: 0;
border-radius: 0px 0px 3px 3px;
background: rgba(0,0,0,0.2);
&.full {
border-radius: 3px 3px 3px 3px;
}
}
.table-seats {
position: absolute;
display: inline-block;
bottom: 0;
left: 50%;
height: 20px;
line-height: 20px;
font-size: 16px;
border-radius: 5px;
margin-left: -16px;
margin-bottom: 4px;
background: black;
background: rgba(0,0,0,0.2);
color: white;
opacity: 0.2;
z-index: 3;
padding: 0 4px;
}
.table-seats-presence {
opacity: 1;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
padding: 0 10px;
position: absolute;
bottom: -5px;
left: 50%;
transform: translate(-50%, -50%);
.cover {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: black;
opacity: 0.4;
z-index: -1;
}
}
.table-handle {
@@ -253,7 +251,6 @@
max-height: 100%;
overflow: hidden;
position: relative;
bottom: 5px;
}
.edit-button.editing {
@@ -1,6 +1,6 @@
/** @odoo-module */
import { Component } from "@odoo/owl";
import { Component, useState } from "@odoo/owl";
import { usePos } from "@point_of_sale/app/store/pos_hook";
export class Table extends Component {
@@ -25,26 +25,64 @@ export class Table extends Component {
setup() {
this.pos = usePos();
this.state = useState({
containerHeight: 0,
containerWidth: 0,
});
}
get fontSize() {
const size = this.state.containerHeight / 3;
return size > 20 ? 20 : size;
}
get badgeStyle() {
if (this.props.table.shape !== "round") {
return `top: -6px; right: -6px;`;
}
const tableHeight = this.state.containerHeight;
const tableWidth = this.state.containerWidth;
const radius = Math.min(tableWidth, tableHeight) / 2;
let left = 0;
let bottom = 0;
if (tableHeight > tableWidth) {
left = radius;
bottom = radius + (tableHeight - tableWidth);
} else {
bottom = radius;
left = radius + (tableWidth - tableHeight);
}
bottom += 0.7 * radius - 8;
left += 0.7 * radius - 8;
return `bottom: ${bottom}px; left: ${left}px;`;
}
get style() {
const table = this.props.table;
let style = "";
let background = table.color ? table.color : "rgb(53, 211, 116)";
let textColor = "white";
let border = "auto";
let boxShadow = "0px 3px rgba(0,0,0,0.07)";
if (!this.isOccupied()) {
background = "transparent";
background = "#00000020";
const rgb = table.floor.background_color
.substring(4, table.floor.background_color.length - 1)
.replace(/ /g, "")
.split(",");
textColor =
(0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255 > 0.5 ? "black" : "white";
border = "3px solid " + table.color;
boxShadow = "none";
}
style += `
border: 3px solid ${table.color};
border-radius: ${table.shape === "round" ? 1000 : 3}px;
background: ${background};
box-shadow: 0px 3px rgba(0,0,0,0.07);
padding: ${table.shape === "round" ? "4px 10px" : "4px 8px"};
color: ${textColor};`;
if (this.pos.floorPlanStyle == "kanban") {
const floor = table.floor;
const index = floor.tables.indexOf(table);
@@ -54,37 +92,35 @@ export class Table extends Component {
const position_h =
widthTable * (index % nbrHorizontal) + 5 + (index % nbrHorizontal) * 10;
const position_v =
widthTable * Math.floor(index / nbrHorizontal) +
5 +
(widthTable + 25) * Math.floor(index / nbrHorizontal) +
10 +
Math.floor(index / nbrHorizontal) * 10;
return `
this.state.containerHeight = widthTable;
this.state.containerWidth = widthTable;
style += `
width: ${widthTable}px;
height: ${widthTable}px;
line-height: ${widthTable}px;
top: ${position_v}px;
left: ${position_h}px;
border: ${border};
border-radius: ${table.shape === "round" ? 1000 : 3}px;
background: ${background};
box-shadow: ${boxShadow};
font-size: ${widthTable >= 150 ? 32 : 16}px;
color: ${textColor};
`;
} else {
return `
this.state.containerHeight = table.height;
this.state.containerWidth = table.width;
style += `
width: ${table.width}px;
height: ${table.height}px;
line-height: ${table.height}px;
top: ${table.position_v}px;
left: ${table.position_h}px;
border: ${border};
border-radius: ${table.shape === "round" ? 1000 : 3}px;
background: ${background};
box-shadow: ${boxShadow};
font-size: ${table.height >= 150 && table.width >= 150 ? 32 : 16}px;
color: ${textColor};
`;
}
style += `
font-size: ${this.fontSize}px;
line-height: ${this.fontSize}px;`;
return style;
}
get fill() {
const customerCount = this.pos.getCustomerCount(this.props.table.id);
@@ -110,18 +146,16 @@ export class Table extends Component {
return !Number.isNaN(result) ? result : 0;
}
get orderCountClass() {
const countClass = { "order-count": true };
if (this.pos.orderPreparationCategories.size) {
const notifications = this._getNotifications();
countClass["notify-printing"] = notifications.printing;
countClass["notify-skipped"] = notifications.skipped;
}
const notifications = this._getNotifications();
const countClass = {
"order-count": true,
"notify-printing": notifications.printing,
"notify-skipped": notifications.skipped,
};
return countClass;
}
get customerCountDisplay() {
return `${this.pos.getCustomerCount(this.props.table.id)}/${
this.props.table.seats
}`;
return `${this.pos.getCustomerCount(this.props.table.id)}/${this.props.table.seats}`;
}
_getNotifications() {
const table = this.props.table;
@@ -133,8 +167,7 @@ export class Table extends Component {
}
isOccupied() {
return (
this.pos.getCustomerCount(this.props.table.id) > 0 ||
this.props.table.order_count > 0
this.pos.getCustomerCount(this.props.table.id) > 0 || this.props.table.order_count > 0
);
}
}
@@ -1,19 +1,19 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates id="template" xml:space="preserve">
<t t-name="pos_restaurant.Table" owl="1">
<div class="table" t-on-click.stop="(ev) => props.onClick(props.table, ev)" t-att-style="style">
<span class="table-cover" t-att-class="{ full: fill >= 1 }" t-att-style="`height: ${Math.ceil(fill * 100)}%`"></span>
<span t-att-class="orderCountClass" t-att-hidden="orderCount === 0">
<t t-esc="orderCount" />
</span>
<span class="label">
<t t-esc="props.table.name" />
</span>
<span class="table-seats">
<div class="infos">
<span class="label">
<t t-esc="props.table.name" />
</span>
<span t-att-class="orderCountClass" t-att-hidden="orderCount === 0" t-att-style="badgeStyle">
<t t-esc="orderCount" />
</span>
</div>
<span class="table-seats">
<div class="cover" t-att-style="`width: ${Math.ceil(fill * 100)}%`" />
<t t-esc="customerCountDisplay" />
</span>
</div>
</t>
</templates>
@@ -18,7 +18,9 @@ patch(Navbar.prototype, "pos_restaurant.Navbar", {
return this._super() || (this.pos.config.module_pos_restaurant && !this.pos.table);
},
onSwitchButtonClick() {
this.pos.floorPlanStyle = this.pos.floorPlanStyle == "kanban" ? "default" : "kanban";
const mode = this.pos.floorPlanStyle == "kanban" ? "default" : "kanban";
localStorage.setItem("floorPlanStyle", mode);
this.pos.floorPlanStyle = mode;
},
toggleEditMode() {
this.pos.toggleEditMode();
@@ -0,0 +1,44 @@
/** @odoo-module */
import { patch } from "@web/core/utils/patch";
import { OrderWidget } from "@point_of_sale/app/screens/product_screen/order/order";
import { AlertPopup } from "@point_of_sale/app/utils/alert_popup/alert_popup";
import { _t } from "@web/core/l10n/translation";
patch(OrderWidget.prototype, "pos_restaurant.OrderWidget", {
releaseTable() {
const currentTable = this.pos.table;
const orderOnTable = this.pos.orders.filter(
(o) => o.tableId === currentTable.id && o.finalized === false
);
const orderOnTableWithOrderline = orderOnTable.find((o) => o.orderlines.length > 0);
if (orderOnTableWithOrderline) {
this.popup.add(AlertPopup, {
title: _t("Open orders"),
body: _t(
"There are open orders on this table. Please close them before releasing the table."
),
});
return;
}
for (const order of orderOnTable) {
this.pos.removeOrder(order);
}
this.pos.showScreen("FloorScreen");
},
get showReleaseBtn() {
if (!this.pos.config.module_pos_restaurant) {
return false;
}
const currentTable = this.pos.table;
const noEmptyOrderOnTable = this.pos.orders.filter(
(o) => o.tableId === currentTable.id && o.finalized === false && o.orderlines.length > 0
);
return noEmptyOrderOnTable.length ? false : true;
},
});
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_restaurant.OrderWidget" t-inherit="point_of_sale.OrderWidget" t-inherit-mode="extension" owl="1">
<xpath expr="//div[hasclass('order-empty')]" position="after">
<button t-if="showReleaseBtn" class="btn btn-primary" style="border:none; font-size: 20px;" t-on-click="releaseTable">Release table</button>
</xpath>
</t>
</templates>
@@ -30,7 +30,7 @@ export class TableGuestsButton extends Component {
});
if (confirmed) {
const guestCount = parseInt(inputNumber, 10) || 1;
const guestCount = parseInt(inputNumber, 10) || 0;
// Set the maximum number possible for an integer
const max_capacity = 2 ** 31 - 1;
if (guestCount > max_capacity) {
@@ -43,6 +43,12 @@ export class TableGuestsButton extends Component {
});
return;
}
if (guestCount == 0 && this.currentOrder.orderlines.length === 0) {
this.pos.removeOrder(this.currentOrder);
this.pos.showScreen("FloorScreen");
}
this.currentOrder.setCustomerCount(guestCount);
}
}
@@ -16,8 +16,16 @@ patch(TicketScreen.prototype, "pos_restaurant.TicketScreen", {
});
},
getTable(order) {
if (order.getTable()) {
return `${order.getTable().floor.name} (${order.getTable().name})`;
const table = order.getTable();
if (table) {
let floorAndTable = "";
if (this.pos.floors && this.pos.floors.length > 1) {
floorAndTable = `${table.floor.name}/`;
}
floorAndTable += table.name;
return floorAndTable;
}
},
//@override
@@ -118,9 +126,7 @@ patch(TicketScreen.prototype, "pos_restaurant.TicketScreen", {
async onDoRefund() {
const order = this.getSelectedSyncedOrder();
if (this.pos.config.module_pos_restaurant && order && !this.pos.table) {
this.pos.setTable(
order.table ? order.table : Object.values(this.pos.tables_by_id)[0]
);
this.pos.setTable(order.table ? order.table : Object.values(this.pos.tables_by_id)[0]);
}
this._super(...arguments);
},
@@ -7,7 +7,7 @@
<t t-esc="nGuests" />
</span>
<span> </span>
<span>Guests</span>
<span>Dine-in Guests</span>
</div>
</t>
@@ -131,17 +131,25 @@ class Check {
}
tableSeatIs(table, val) {
return [
{
content: `Unselect table`,
trigger: `.floor-map`,
},
{
content: `number of seats in table '${table}' is '${val}'`,
trigger: `.floor-map .table .label:contains("${table}") ~ .table-seats:contains("${val}")`,
trigger: `.floor-map .table .infos:has(.label:contains("${table}")) ~ .table-seats:contains("${val}")`,
run: function () {},
},
{
content: `click table '${table}'`,
trigger: `.floor-map .table .label:contains("${table}")`,
},
];
}
orderCountSyncedInTableIs(table, count) {
return [
{
trigger: `.floor-map .table .order-count:contains("${count}") ~ .label:contains("${table}")`,
trigger: `.floor-map .table .label:contains("${table}") ~ .order-count:contains("${count}")`,
run: function () {},
},
];
@@ -28,6 +28,14 @@
<setting help="Split total or order lines" id="iface_splitbill" attrs="{'invisible': [('pos_module_pos_restaurant', '=', False)]}">
<field name="pos_iface_splitbill" string="Allow Bill Splitting"/>
</setting>
<setting help="Online reservation for restaurant" attrs="{'invisible': [('pos_module_pos_restaurant', '=', False)]}">
<field name="pos_module_pos_restaurant_appointment" string="Table Booking" widget="upgrade_boolean" />
<div class="content-group" id="pos_table_booking" attrs="{'invisible': [('pos_module_pos_restaurant_appointment', '=', False)]}">
<div class="text-warning mt16 mb4">
Save this page and come back here to set up the feature.
</div>
</div>
</setting>
</block>
</block>
<div id="tip_product" position="after">