From 53f9e5845f34beaed523a4c6e0e2e7dd1cfd9025 Mon Sep 17 00:00:00 2001 From: "Adrien Guilliams (adgu)" Date: Mon, 19 Dec 2022 14:08:31 +0000 Subject: [PATCH] [IMP] point_of_sale, pos_restaurant: Add cross-orders feature for point_of_sale and pos_restaurant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently the below use-cases are hardly supportable, though quite common: Retail: Sell product in one shop and return it in another one Restaurant: Managing payment when having multiple checkout desks, and multiple waiters (only taking orders) This because currently: PoS orders are only known by the cashier desk (pos.config) they were created from One floor map can only be linked to one cashier desk at a time More over, with the Self-Service coming along the way (where kiosk orders must be accessible from a cashier desk), this need must be supported. For restaurant, floor plans can be linked to multiple cashier desks, ongoing orders are shared between trusted PoS config (through floor plans for restaurant and according to the setting "Trusted PoS config" for the retails), and past orders can be accessible from any desks within a same DB. closes odoo/odoo#109216 Related: odoo/upgrade#4393 Related: odoo/enterprise#37955 Signed-off-by: Trinh Jacky (trj) --- addons/point_of_sale/models/pos_config.py | 24 +- addons/point_of_sale/models/pos_order.py | 225 +++++++++++++++- addons/point_of_sale/models/pos_session.py | 44 +++- .../models/res_config_settings.py | 11 + .../static/src/app/navbar/navbar.js | 25 +- .../ControlButtons/SaveButton.js | 51 ++++ .../js/Screens/ProductScreen/ProductScreen.js | 30 +++ .../js/Screens/ReceiptScreen/ReceiptScreen.js | 12 + .../js/Screens/TicketScreen/TicketScreen.js | 22 +- addons/point_of_sale/static/src/js/models.js | 244 +++++++++++++++++- addons/point_of_sale/static/src/scss/pos.scss | 14 +- .../ControlButtons/SaveButton.xml | 11 + .../Screens/ReceiptScreen/ReceiptScreen.xml | 19 +- .../static/tests/tours/TicketScreen.tour.js | 4 +- .../tours/helpers/ReceiptScreenTourMethods.js | 2 +- .../tours/helpers/TicketScreenTourMethods.js | 8 + .../views/res_config_settings_views.xml | 11 + addons/pos_hr/models/pos_order.py | 14 + .../data/pos_restaurant_demo.xml | 4 +- addons/pos_restaurant/models/pos_config.py | 4 +- addons/pos_restaurant/models/pos_order.py | 175 +------------ .../pos_restaurant/models/pos_restaurant.py | 17 +- addons/pos_restaurant/models/pos_session.py | 3 +- .../models/res_config_settings.py | 5 +- .../static/src/app/navbar/navbar.js | 23 +- .../js/Screens/ReceiptScreen/ReceiptScreen.js | 7 + .../static/src/js/Screens/TicketScreen.js | 4 +- addons/pos_restaurant/static/src/js/models.js | 164 ++++-------- .../static/tests/tours/TipScreen.tour.js | 2 +- addons/pos_restaurant/tests/test_frontend.py | 4 +- .../views/pos_restaurant_views.xml | 8 +- 31 files changed, 827 insertions(+), 364 deletions(-) create mode 100644 addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SaveButton.js create mode 100644 addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SaveButton.xml diff --git a/addons/point_of_sale/models/pos_config.py b/addons/point_of_sale/models/pos_config.py index 1c278245df7..73e828c317a 100644 --- a/addons/point_of_sale/models/pos_config.py +++ b/addons/point_of_sale/models/pos_config.py @@ -5,7 +5,7 @@ from datetime import datetime from uuid import uuid4 import pytz -from odoo import api, fields, models, tools, _ +from odoo import api, fields, models, _ from odoo.exceptions import ValidationError, UserError @@ -175,6 +175,9 @@ class PosConfig(models.Model): limited_partners_amount = fields.Integer(default=100) partner_load_background = fields.Boolean(default=True) auto_validate_terminal_payment = fields.Boolean(default=True, help="Automatically validates orders paid with a payment terminal.") + trusted_config_ids = fields.Many2many("pos.config", relation="pos_config_trust_relation", column1="is_trusting", + column2="is_trusted", string="Trusted Point of Sale Configurations", + domain="[('id', '!=', pos_config_id), ('module_pos_restaurant', '=', False)]") @api.depends('payment_method_ids') def _compute_cash_control(self): @@ -364,6 +367,13 @@ class PosConfig(models.Model): if len(cash_method.journal_id.pos_payment_method_ids) > 1: raise ValidationError(_("You cannot use the same journal on multiples cash payment methods.")) + @api.constrains('trusted_config_ids') + def _check_trusted_config_ids_currency(self): + for config in self: + for trusted_config in config.trusted_config_ids: + if trusted_config.currency_id != config.currency_id: + raise ValidationError(_("You cannot share open orders with configuration that does not use the same currency.")) + def name_get(self): result = [] for config in self: @@ -411,6 +421,12 @@ class PosConfig(models.Model): forbidden_fields = [] for key in self._get_forbidden_change_fields(): if key in vals.keys(): + if key == 'use_pricelist' and vals[key]: + continue + if key == 'available_pricelist_ids': + removed_pricelist = set(self.available_pricelist_ids.ids) - set(vals[key][0][2]) + if len(removed_pricelist) == 0: + continue field_name = self._fields[key].get_description(self.env)["string"] forbidden_fields.append(field_name) if len(forbidden_fields) > 0: @@ -703,3 +719,9 @@ class PosConfig(models.Model): 'res_id': self.id, 'context': {'pos_config_open_modal': True}, } + + def _add_trusted_config_id(self, config_id): + self.trusted_config_ids += config_id + + def _remove_trusted_config_id(self, config_id): + self.trusted_config_ids -= config_id diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index bfc824d2600..7a662a7d722 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import logging -from datetime import timedelta +from datetime import datetime from markupsafe import Markup from functools import partial from itertools import groupby @@ -1004,14 +1004,213 @@ class PosOrder(models.Model): mail = self.env['mail.mail'].sudo().create(self._prepare_mail_values(name, client, ticket)) mail.send() + def _get_order_lines(self, orders): + """Add pos_order_lines to the orders. + + The function doesn't return anything but adds the results directly to the orders. + + :param orders: orders for which the order_lines are to be requested. + :type orders: pos.order. + """ + order_lines = self.env['pos.order.line'].search_read( + domain=[('order_id', 'in', [to['id'] for to in orders])], + fields=self._get_fields_for_order_line()) + + if order_lines != []: + self._get_pack_lot_lines(order_lines) + + extended_order_lines = [] + for order_line in order_lines: + extended_order_lines.append([0, 0, self._prepare_order_line(order_line)]) + + for order_id, order_lines in groupby(extended_order_lines, key=lambda x: x[2]['order_id']): + next(order for order in orders if order['id'] == order_id[0])['lines'] = list(order_lines) + + def _get_pack_lot_lines(self, order_lines): + """Add pack_lot_lines to the order_lines. + + The function doesn't return anything but adds the results directly to the order_lines. + + :param order_lines: order_lines for which the pack_lot_lines are to be requested. + :type order_lines: pos.order.line. + """ + pack_lots = self.env['pos.pack.operation.lot'].search_read( + domain=[('pos_order_line_id', 'in', [order_line['id'] for order_line in order_lines])], + fields=[ + 'id', + 'lot_name', + 'pos_order_line_id' + ]) + for pack_lot in pack_lots: + pack_lot['order_line'] = pack_lot['pos_order_line_id'][0] + pack_lot['server_id'] = pack_lot['id'] + + del pack_lot['pos_order_line_id'] + del pack_lot['id'] + + for order_line_id, pack_lot_ids in groupby(pack_lots, key=lambda x: x['order_line']): + next(order_line for order_line in order_lines if order_line['id'] == order_line_id)['pack_lot_ids'] = list(pack_lot_ids) + + def _prepare_order_line(self, order_line): + """Method that will allow the cleaning of values to send the correct information. + :param order_line: order_line that will be cleaned. + :type order_line: pos.order.line. + :returns: dict -- dict representing the order line's values. + """ + order_line["product_id"] = order_line["product_id"][0] + order_line["server_id"] = order_line["id"] + + del order_line["id"] + if not "pack_lot_ids" in order_line: + order_line["pack_lot_ids"] = [] + else: + order_line["pack_lot_ids"] = [[0, 0, lot] for lot in order_line["pack_lot_ids"]] + return order_line + + def _get_fields_for_payment_lines(self): + return [ + 'id', + 'amount', + 'pos_order_id', + 'payment_method_id', + 'card_type', + 'cardholder_name', + 'transaction_id', + 'payment_status' + ] + + def _get_payments_lines_list(self, orders): + payment_lines = self.env['pos.payment'].search_read( + domain=[('pos_order_id', 'in', [po['id'] for po in orders])], + fields=self._get_fields_for_payment_lines()) + + extended_payment_lines = [] + for payment_line in payment_lines: + payment_line['server_id'] = payment_line['id'] + payment_line['payment_method_id'] = payment_line['payment_method_id'][0] + + del payment_line['id'] + extended_payment_lines.append([0, 0, payment_line]) + return extended_payment_lines + + def _get_payment_lines(self, orders): + """Add account_bank_statement_lines to the orders. + + The function doesn't return anything but adds the results directly to the orders. + + :param orders: orders for which the payment_lines are to be requested. + :type orders: pos.order. + """ + extended_payment_lines = self._get_payments_lines_list(orders) + for order_id, payment_lines in groupby(extended_payment_lines, key=lambda x: x[2]['pos_order_id']): + next(order for order in orders if order['id'] == order_id[0])['statement_ids'] = list(payment_lines) + + def _get_fields_for_draft_order(self): + return [ + 'id', + 'pricelist_id', + 'partner_id', + 'sequence_number', + 'session_id', + 'pos_reference', + 'create_uid', + 'create_date', + 'fiscal_position_id', + 'to_invoice', + 'access_token', + 'ticket_code', + ] + + @api.model + def get_draft_share_order_ids(self, config_id): + """Search for 'draft' orders that satisfy the given domain.""" + config = self.env['pos.config'].browse(config_id) + default_domain = ['&', ('state', '=', 'draft'), '|', ('config_id', '=', config_id), ('config_id', 'in', config.trusted_config_ids.ids)] + orders = self.search_read( + domain=default_domain, + fields=self._get_fields_for_draft_order()) + + self._get_order_lines(orders) + self._get_payment_lines(orders) + + self._prepare_order(orders) + + return orders + + def is_already_paid(self): + return self.state == "paid" + + @api.model + def _prepare_order(self, orders): + timezone = pytz.timezone(self._context.get('tz') or self.env.user.tz or 'UTC') + for order in orders: + order['pos_session_id'] = order['session_id'][0] + order['uid'] = re.search(r"\d{5,}-\d{3,}-\d{4,}", order['pos_reference']).group(0) + order['name'] = order['pos_reference'] + order['creation_date'] = order['create_date'].astimezone(timezone) + order['server_id'] = order['id'] + if order['fiscal_position_id']: + order['fiscal_position_id'] = order['fiscal_position_id'][0] + if order['pricelist_id']: + order['pricelist_id'] = order['pricelist_id'][0] + if order['partner_id']: + order['partner_id'] = order['partner_id'][0] + + if not 'lines' in order: + order['lines'] = [] + if not 'statement_ids' in order: + order['statement_ids'] = [] + + del order['id'] + del order['session_id'] + del order['pos_reference'] + del order['create_date'] + + @api.model + def remove_from_ui(self, server_ids): + """ Remove orders from the frontend PoS application + + Remove orders from the server by id. + :param server_ids: list of the id's of orders to remove from the server. + :type server_ids: list. + :returns: list -- list of db-ids for the removed orders. + """ + orders = self.search([('id', 'in', server_ids), ('state', '=', 'draft')]) + orders.write({'state': 'cancel'}) + # TODO Looks like delete cascade is a better solution. + orders.mapped('payment_ids').sudo().unlink() + orders.sudo().unlink() + return orders.ids + @api.model def search_paid_order_ids(self, config_id, domain, limit, offset): """Search for 'paid' orders that satisfy the given domain, limit and offset.""" - default_domain = ['&', ('config_id', '=', config_id), '!', '|', ('state', '=', 'draft'), ('state', '=', 'cancelled')] - real_domain = AND([domain, default_domain]) - ids = self.search(AND([domain, default_domain]), limit=limit, offset=offset).ids + default_domain = [('state', '!=', 'draft'), ('state', '!=', 'cancelled')] + if domain == []: + real_domain = AND([[['config_id', '=', config_id]], default_domain]) + else: + real_domain = AND([domain, default_domain]) + orders = self.search(real_domain, limit=limit, offset=offset) + # We clean here the orders that does not have the same currency. + # As we cannot use currency_id in the domain (because it is not a stored field), + # we must do it after the search. + pos_config = self.env['pos.config'].browse(config_id) + orders.filtered(lambda order: order.currency_id == pos_config.currency_id) + orderlines = self.env['pos.order.line'].search(['|', ('refunded_orderline_id.order_id', 'in', orders.ids), ('order_id', 'in', orders.ids)]) + + # We will return to the frontend the ids and the date of their last modification + # so that it can compare to the last time it fetched the orders and can ask to fetch + # orders that are not up-to-date. + # The date of their last modification is either the last time one of its orderline has changed, + # or the last time a refunded orderline related to it has changed. + orders_info = defaultdict(lambda: datetime.min) + for orderline in orderlines: + key_order = orderline.order_id.id if orderline.order_id in orders \ + else orderline.refunded_orderline_id.order_id.id + if orders_info[key_order] < orderline.write_date: + orders_info[key_order] = orderline.write_date totalCount = self.search_count(real_domain) - return {'ids': ids, 'totalCount': totalCount} + return {'ordersInfo': list(orders_info.items()), 'totalCount': totalCount} def _export_for_ui(self, order): timezone = pytz.timezone(self._context.get('tz') or self.env.user.tz or 'UTC') @@ -1044,10 +1243,18 @@ class PosOrder(models.Model): def _get_fields_for_order_line(self): """This function is here to be overriden""" - return [] - - def _prepare_order_line(self, order_line): - return order_line + fields = [ + 'id', + 'discount', + 'product_id', + 'price_unit', + 'order_id', + 'qty', + 'full_product_name', + 'customer_note', + 'price_extra', + ] + return fields def export_for_ui(self): """ Returns a list of dict with each item having similar signature as the return of diff --git a/addons/point_of_sale/models/pos_session.py b/addons/point_of_sale/models/pos_session.py index 5690412b5f9..9cd526bb013 100644 --- a/addons/point_of_sale/models/pos_session.py +++ b/addons/point_of_sale/models/pos_session.py @@ -1862,12 +1862,7 @@ class PosSession(models.Model): for pricelist in pricelists: pricelist['items'] = [] - pricelist_by_id = {pricelist['id']: pricelist for pricelist in pricelists} - pricelist_item_domain = [('pricelist_id', 'in', [p['id'] for p in pricelists])] - for item in self.env['product.pricelist.item'].search_read(pricelist_item_domain, self._product_pricelist_item_fields()): - pricelist_by_id[item['pricelist_id'][0]]['items'].append(item) - - return pricelists + return self._prepare_product_pricelists(pricelists) def _loader_params_product_category(self): return {'search_params': {'domain': [], 'fields': ['name', 'parent_id']}} @@ -2082,6 +2077,43 @@ class PosSession(models.Model): else: return list(total_sold_per_user_per_category[0].items()), list(total_refund_per_user_per_category[0].items()) + def get_pos_ui_product_pricelists_by_ids(self, pricelist_ids): + params = self._loader_params_product_pricelist() + params['search_params']['domain'] = [('id', 'in', pricelist_ids)] + pricelists = self.env['product.pricelist'].search_read(**params['search_params']) + for pricelist in pricelists: + if not self.config_id.use_pricelist: + self.config_id.use_pricelist = True + pricelist_id = self.env['product.pricelist'].browse(pricelist['id']) + self.config_id.available_pricelist_ids += pricelist_id + pricelist['items'] = [] + + return self._prepare_product_pricelists(pricelists) + + def _prepare_product_pricelists(self, pricelists): + pricelist_by_id = {pricelist['id']: pricelist for pricelist in pricelists} + pricelist_item_domain = [('pricelist_id', 'in', [p['id'] for p in pricelists])] + for item in self.env['product.pricelist.item'].search_read(pricelist_item_domain, self._product_pricelist_item_fields()): + pricelist_by_id[item['pricelist_id'][0]]['items'].append(item) + + return pricelists + + def get_pos_ui_account_fiscal_positions_by_ids(self, fp_ids): + params = self._loader_params_account_fiscal_position() + params['search_params']['domain'] = [('id', 'in', fp_ids)] + fps = self.env['account.fiscal.position'].search_read(**params['search_params']) + fiscal_position_tax_ids = sum([fpos['tax_ids'] for fpos in fps], []) + fiscal_position_tax = self.env['account.fiscal.position.tax'].search_read([('id', 'in', fiscal_position_tax_ids)]) + fiscal_position_by_id = {fpt['id']: fpt for fpt in fiscal_position_tax} + for fiscal_position in fps: + if not self.config_id.tax_regime_selection: + self.config_id.tax_regime_selection = True + fiscal_position_id = self.env['account.fiscal.position'].browse(fiscal_position['id']) + self.config_id.fiscal_position_ids += fiscal_position_id + fiscal_position['fiscal_position_taxes_by_id'] = {tax_id: fiscal_position_by_id[tax_id] for tax_id in fiscal_position['tax_ids']} + + return fps + class ProcurementGroup(models.Model): _inherit = 'procurement.group' diff --git a/addons/point_of_sale/models/res_config_settings.py b/addons/point_of_sale/models/res_config_settings.py index a2564d155f7..057d6ea5dea 100644 --- a/addons/point_of_sale/models/res_config_settings.py +++ b/addons/point_of_sale/models/res_config_settings.py @@ -105,6 +105,7 @@ class ResConfigSettings(models.TransientModel): pos_warehouse_id = fields.Many2one(related='pos_config_id.warehouse_id', readonly=False, string="Warehouse (PoS)") point_of_sale_use_ticket_qr_code = fields.Boolean(related='company_id.point_of_sale_use_ticket_qr_code', readonly=False) pos_auto_validate_terminal_payment = fields.Boolean(related='pos_config_id.auto_validate_terminal_payment', readonly=False, string="Automatically validates orders paid with a payment terminal.") + pos_trusted_config_ids = fields.Many2many(related='pos_config_id.trusted_config_ids', readonly=False) point_of_sale_ticket_unique_code = fields.Boolean(related='company_id.point_of_sale_ticket_unique_code', readonly=False) @api.model_create_multi @@ -313,3 +314,13 @@ class ResConfigSettings(models.TransientModel): res_config.pos_iface_customer_facing_display_via_proxy = False else: res_config.pos_iface_customer_facing_display_via_proxy = res_config.pos_config_id.iface_customer_facing_display_via_proxy + + @api.onchange('pos_trusted_config_ids') + def _onchange_trusted_config_ids(self): + for config in self: + removed_trusted_configs = set(config.pos_config_id.trusted_config_ids.ids) - set(config.pos_trusted_config_ids.ids) + for old in config.pos_config_id.trusted_config_ids: + if config.pos_config_id.id not in old.trusted_config_ids.ids: + old._add_trusted_config_id(config.pos_config_id) + if old.id in removed_trusted_configs: + old._remove_trusted_config_id(config.pos_config_id) diff --git a/addons/point_of_sale/static/src/app/navbar/navbar.js b/addons/point_of_sale/static/src/app/navbar/navbar.js index 21cf1d283aa..b2be2916d3d 100644 --- a/addons/point_of_sale/static/src/app/navbar/navbar.js +++ b/addons/point_of_sale/static/src/app/navbar/navbar.js @@ -31,6 +31,7 @@ export class Navbar extends Component { this.pos = usePos(); this.debug = useService("debug"); this.popup = useService("popup"); + this.notification = useService("pos_notification"); } get customerFacingDisplayButtonIsShown() { return this.env.pos.config.iface_customer_facing_display; @@ -40,14 +41,34 @@ export class Navbar extends Component { this.popup.add(CashMovePopup); } - onTicketButtonClick() { + async onTicketButtonClick() { if (this.isTicketScreenShown) { this.pos.closeScreen(); } else { - this.pos.showScreen("TicketScreen"); + if (this._shouldLoadOrders()) { + try { + this.env.pos.setLoadingOrderState(true); + const message = await this.env.pos._syncAllOrdersFromServer(); + if (message) { + this.notification.add( + _.str.sprintf(message), + 5000 + ); + } + } finally { + this.env.pos.setLoadingOrderState(false); + this.pos.showScreen("TicketScreen"); + } + } else { + this.pos.showScreen("TicketScreen"); + } } } + _shouldLoadOrders() { + return this.env.pos.config.trusted_config_ids.length > 0; + } + get isTicketScreenShown() { return this.pos.mainScreen.component === TicketScreen; } diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SaveButton.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SaveButton.js new file mode 100644 index 00000000000..158039256c6 --- /dev/null +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SaveButton.js @@ -0,0 +1,51 @@ +/** @odoo-module */ + +import { Component } from "@odoo/owl"; +import { ProductScreen } from "@point_of_sale/js/Screens/ProductScreen/ProductScreen"; +import { useService } from "@web/core/utils/hooks"; + +export class SaveButton extends Component { + static template = "point_of_sale.SaveButton"; + + setup() { + super.setup(); + this.notification = useService("pos_notification"); + } + onClick() { + const orderline = this.env.pos.get_order().get_selected_orderline(); + if (!orderline) { + this.notification.add( + _.str.sprintf( + this.env._t('You cannot save an empty order') + ), + 3000 + ); + return; + } + this._selectEmptyOrder(); + this.notification.add( + _.str.sprintf( + this.env._t('Order saved for later') + ), + 3000 + ); + } + _selectEmptyOrder() { + const orders = this.env.pos.get_order_list(); + let emptyOrders = orders.filter((order) => order.is_empty()); + if (emptyOrders.length > 0) { + this.env.pos.sendDraftToServer(); + this.env.pos.set_order(emptyOrders[0]); + } else { + this.env.pos.add_new_order(); + } + } + +} + +ProductScreen.addControlButton({ + component: SaveButton, + condition: function () { + return this.env.pos.config.trusted_config_ids.length > 0; + }, +}); diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js index 44e57cb3f34..307593bc885 100644 --- a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js @@ -11,6 +11,7 @@ import { ErrorPopup } from "@point_of_sale/js/Popups/ErrorPopup"; import { ErrorBarcodePopup } from "@point_of_sale/js/Popups/ErrorBarcodePopup"; import { ConfirmPopup } from "@point_of_sale/js/Popups/ConfirmPopup"; import { ControlButtonPopup } from "@point_of_sale/js/Popups/ControlButtonPopup"; +import { ConnectionLostError } from "@web/core/network/rpc_service"; import { ActionpadWidget } from "./ActionpadWidget"; import { MobileOrderWidget } from "../../Misc/MobileOrderWidget"; @@ -35,6 +36,7 @@ export class ProductScreen extends ControlButtonsMixin(Component) { this.pos = usePos(); this.popup = useService("popup"); this.orm = useService("orm"); + this.notification = useService("pos_notification"); this.numberBuffer = useService("number_buffer"); onMounted(this.onMounted); // Call `reset` when the `onMounted` callback in `numberBuffer.use` is done. @@ -232,6 +234,34 @@ export class ProductScreen extends ControlButtonsMixin(Component) { } } async onClickPay() { + if (this.env.pos.get_order().server_id) { + try { + const isPaid = await this.orm.call("pos.order", "is_already_paid", [ + this.env.pos.get_order().server_id, + ]); + if (isPaid) { + const searchDetails = { fieldName: "RECEIPT_NUMBER", searchTerm: this.env.pos.get_order().uid } + this.pos.showScreen("TicketScreen", { + ui: { filter: "SYNCED", searchDetails } + }); + this.notification.add( + _.str.sprintf( + this.env._t('The order has been already paid.') + ), + 3000 + ); + this.env.pos.removeOrder(this.env.pos.get_order(), false); + this.env.pos.add_new_order(); + return; + } + } catch (error) { + if (!(error.message instanceof ConnectionLostError)) { + throw error; + } + // Reject error in a separate stack to display the offline popup, but continue the flow + Promise.reject(error); + } + } if ( this.env.pos .get_order() diff --git a/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js index a05035d6501..7a8b2e5727b 100644 --- a/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js +++ b/addons/point_of_sale/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js @@ -100,6 +100,9 @@ export class ReceiptScreen extends AbstractReceiptScreen { get nextScreen() { return { name: "ProductScreen" }; } + get ticketScreen() { + return { name: "TicketScreen" }; + } whenClosing() { this.orderDone(); } @@ -131,6 +134,15 @@ export class ReceiptScreen extends AbstractReceiptScreen { this.env.pos.send_current_order_to_customer_facing_display(); } } + resumeOrder() { + this.env.pos.removeOrder(this.currentOrder); + this.env.pos.selectNextOrder(); + const { name, props } = this.ticketScreen; + this.pos.showScreen(name, props); + } + isResumeVisible() { + return this.env.pos.get_order_list().length > 1; + } async printReceipt() { this.buttonPrintReceipt.el.className = "fa fa-fw fa-spin fa-circle-o-notch"; const currentOrder = this.currentOrder; diff --git a/addons/point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js b/addons/point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js index 5691a3f8590..7fc6911cf64 100644 --- a/addons/point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js +++ b/addons/point_of_sale/static/src/js/Screens/TicketScreen/TicketScreen.js @@ -4,7 +4,7 @@ import { Order } from "@point_of_sale/js/models"; import { IndependentToOrderScreen } from "@point_of_sale/js/Misc/IndependentToOrderScreen"; import { registry } from "@web/core/registry"; import { useService } from "@web/core/utils/hooks"; -import { parse } from "web.field_utils"; +import { parse, format } from "web.field_utils"; import { ErrorPopup } from "@point_of_sale/js/Popups/ErrorPopup"; import { ConfirmPopup } from "@point_of_sale/js/Popups/ConfirmPopup"; @@ -144,6 +144,9 @@ export class TicketScreen extends IndependentToOrderScreen { } this.env.pos.removeOrder(order); } + if (this.env.pos.isOpenOrderShareable()) { + this.env.pos._removeOrdersFromServer(); + } } async onNextPage() { if (this._state.syncedOrders.currentPage < this._getLastPage()) { @@ -543,6 +546,9 @@ export class TicketScreen extends IndependentToOrderScreen { }; } _setOrder(order) { + if (this.env.pos.isOpenOrderShareable()) { + this.env.pos.sendDraftToServer(); + } this.env.pos.set_order(order); this.close(); } @@ -650,16 +656,18 @@ export class TicketScreen extends IndependentToOrderScreen { const limit = this._state.syncedOrders.nPerPage; const offset = (this._state.syncedOrders.currentPage - 1) * this._state.syncedOrders.nPerPage; - const { ids, totalCount } = await this.orm.call("pos.order", "search_paid_order_ids", [], { + const { ordersInfo, totalCount } = await this.orm.call("pos.order", "search_paid_order_ids", [], { config_id: this.env.pos.config.id, domain, limit, offset, }); - const idsNotInCache = ids.filter((id) => !(id in this._state.syncedOrders.cache)); - if (idsNotInCache.length > 0) { + const idsNotInCache = ordersInfo.filter((orderInfo) => !(orderInfo[0] in this._state.syncedOrders.cache)); + const idsNotUpToDate = ordersInfo.filter((orderInfo) => format.datetime(moment(orderInfo[1]), {}, {}) > format.datetime(moment(this._state.syncedOrders.cacheDate), {}, { timezone: false })); + const idsToLoad = idsNotInCache.concat(idsNotUpToDate).map((info) => info[0]); + if (idsToLoad.length > 0) { const fetchedOrders = await this.orm.call("pos.order", "export_for_ui", [ - idsNotInCache, + idsToLoad, ]); // Check for missing products and partners and load them in the PoS await this.env.pos._loadMissingProducts(fetchedOrders); @@ -672,7 +680,11 @@ export class TicketScreen extends IndependentToOrderScreen { { pos: this.env.pos, json: order } ); }); + //Update the datetime indicator of the cache refresh + this._state.syncedOrders.cacheDate = new Date(); } + + const ids = ordersInfo.map((info) => info[0]); this._state.syncedOrders.totalCount = totalCount; this._state.syncedOrders.toShow = ids.map((id) => this._state.syncedOrders.cache[id]); } diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 14ebdcacd63..fc239199a80 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -128,6 +128,7 @@ export class PosGlobalState extends PosModel { toShow: [], nPerPage: 80, totalCount: null, + cacheDate: null, }, ui: { selectedSyncedOrderId: null, @@ -139,6 +140,9 @@ export class PosGlobalState extends PosModel { }, }; + this.ordersToUpdateSet = new Set(); // used to know which orders need to be sent to the back end when syncing + this.loadingOrderState = false; // used to prevent orders fetched to be put in the update set during the reactive change + // these dynamic attributes can be watched for change by other models or widgets Object.assign(this, { synch: { status: "connected", pending: 0 }, @@ -382,7 +386,7 @@ export class PosGlobalState extends PosModel { * Remove the order passed in params from the list of orders * @param order */ - removeOrder(order) { + removeOrder(order, removeFromServer = true) { this.orders.remove(order); this.db.remove_unpaid_order(order); for (const line of order.get_orderlines()) { @@ -390,6 +394,14 @@ export class PosGlobalState extends PosModel { delete this.toRefundLines[line.refunded_orderline_id]; } } + if (this.isOpenOrderShareable() && removeFromServer) { + if (this.ordersToUpdateSet.has(order)) { + this.ordersToUpdateSet.delete(order); + } + if (order.server_id && !order.finalized) { + this.db.set_order_to_remove_from_server(order); + } + } } /** @@ -407,6 +419,9 @@ export class PosGlobalState extends PosModel { } _onReactiveOrderUpdated(order) { order.save_to_db(); + if (this.isOpenOrderShareable()) { + this.ordersToUpdateSet.add(order); + } } createReactiveOrder(json) { const options = { pos: this }; @@ -425,11 +440,44 @@ export class PosGlobalState extends PosModel { } // creates a new empty order and sets it as the current order add_new_order() { + if (this.isOpenOrderShareable()) { + this.sendDraftToServer(); + } const order = this.createReactiveOrder(); this.orders.add(order); this.selectedOrder = order; return order; } + selectNextOrder() { + if (this.orders.length > 0) { + this.selectedOrder = this.orders[0]; + } else { + this.add_new_order(); + } + } + async sendDraftToServer() { + const ordersUidsToSync = [...this.ordersToUpdateSet].map((order) => order.uid); + const ordersToSync = this.db.get_unpaid_orders_to_sync(ordersUidsToSync); + const ordersResponse = await this._save_to_server(ordersToSync, { draft: true }); + const orders = [...this.ordersToUpdateSet].map((order) => order); + ordersResponse.forEach((orderResponseData) => + this._updateOrder(orderResponseData, orders) + ); + this.ordersToUpdateSet.clear(); + } + addOrderToUpdateSet() { + this.ordersToUpdateSet.add(this.selectedOrder) + } + // created this hook for modularity + _updateOrder(ordersResponseData, orders) { + const order = orders.find( + (order) => order.name === ordersResponseData.pos_reference + ); + if (order) { + order.server_id = ordersResponseData.id; + return order; + } + } /** * Load the locally saved unpaid orders for this PoS Config. * @@ -438,6 +486,7 @@ export class PosGlobalState extends PosModel { * Only if tho order has orderlines. */ async load_orders() { + this.loadingOrderState = true; var jsons = this.db.get_unpaid_orders(); await this._loadMissingProducts(jsons); await this._loadMissingPartners(jsons); @@ -470,6 +519,7 @@ export class PosGlobalState extends PosModel { this.orders.add(order); } } + this.loadingOrderState = false; } async _loadMissingProducts(orders) { const missingProductIds = new Set([]); @@ -558,6 +608,188 @@ export class PosGlobalState extends PosModel { i += 1; } while (partners.length); } + setLoadingOrderState(bool) { + this.loadingOrderState = bool; + } + async _removeOrdersFromServer() { + const removedOrdersIds = this.db.get_ids_to_remove_from_server(); + if (removedOrdersIds.length === 0) { + return; + } + + this.set_synch("connecting", removedOrdersIds.length); + try { + const removeOrdersResponseData = await this.env.services.orm.silent.call( + "pos.order", + "remove_from_ui", + [removedOrdersIds] + ); + this.set_synch("connected"); + this._postRemoveFromServer(removedOrdersIds, removeOrdersResponseData); + } catch (reason) { + const error = reason.message; + if (error.code === 200) { + // Business Logic Error, not a connection problem + //if warning do not need to display traceback!! + if (error.data.exception_type == "warning") { + delete error.data.debug; + } + } + // important to throw error here and let the rendering component handle the error + console.warn("Failed to remove orders:", removedOrdersIds); + throw error; + } + } + _postRemoveFromServer(serverIds, data) { + this.db.set_ids_removed_from_server(serverIds); + } + _replaceOrders(ordersToReplace, newOrdersJsons) { + ordersToReplace.forEach((order) => { + // We don't remove the validated orders because we still want to see them in the ticket screen. + // Orders in 'ReceiptScreen' or 'TipScreen' are validated orders. + if (this._shouldRemoveOrder(order)) { + this.removeOrder(order, false); + } + }); + let removeSelected = true; + newOrdersJsons.forEach((json) => { + let isSelectedOrder = this._createOrder(json); + if (removeSelected && isSelectedOrder) { + removeSelected = false; + } + }); + if (this._shouldRemoveSelectedOrder(removeSelected)) { + this._removeSelectedOrder(); + } + } + _shouldRemoveOrder(order) { + return (!this.selectedOrder || (this.selectedOrder.uid != order.uid)) && order.server_id && !order.finalized; + } + _shouldRemoveSelectedOrder(removeSelected) { + return removeSelected && this.selectedOrder.server_id && !this.selectedOrder.finalized; + } + _shouldCreateOrder(json) { + return json.uid != this.selectedOrder.uid; + } + _isSelectedOrder(json) { + return json.uid == this.selectedOrder.uid; + } + _createOrder(json) { + if (this._shouldCreateOrder(json)) { + const order = this.createReactiveOrder(json); + this.orders.add(order); + } + return this._isSelectedOrder(json); + } + _removeSelectedOrder() { + this.removeOrder(this.selectedOrder, false); + const orderList = this.get_order_list(); + if (orderList.length != 0) { + this.set_order(orderList[0]); + } + } + async _syncAllOrdersFromServer() { + await this._removeOrdersFromServer(); + const ordersJson = await this._getOrdersJson(); + let message = null; + message = await this._addPricelists(ordersJson); + let messageFp = null; + messageFp = await this._addFiscalPositions(ordersJson); + if (messageFp) { + if (message) { + message += "\n" + messageFp; + } else { + message = messageFp; + } + } + const allOrders = [...this.get_order_list()]; + this._replaceOrders(allOrders, ordersJson); + this.sortOrders(); + return message; + } + async _getOrdersJson() { + return await this.env.services.orm.call("pos.order", "get_draft_share_order_ids", [], { + config_id: this.config.id, + }); + } + async _addPricelists(ordersJson) { + let pricelistsToGet = []; + ordersJson.forEach((order) => { + let found = false; + for (let pricelist of this.pricelists) { + if (pricelist.id === order.pricelist_id) { + found = true; + break; + } + } + if (!found && order.pricelist_id) { + pricelistsToGet.push(order.pricelist_id); + } + }); + let message = null; + if (pricelistsToGet.length > 0) { + const pricelistsJson = await this._getPricelistJson(pricelistsToGet); + message = this._addPosPricelists(pricelistsJson); + } + return message; + } + async _getPricelistJson(pricelistsToGet) { + return await this.env.services.orm.call("pos.session", "get_pos_ui_product_pricelists_by_ids", [ + [odoo.pos_session_id], + pricelistsToGet, + ]); + } + _addPosPricelists(pricelistsJson) { + if (!this.config.use_pricelist) { + this.config.use_pricelist = true; + } + this.pricelists.push(...pricelistsJson); + let message = ""; + const pricelistsNames = pricelistsJson.map((pricelist) => { + return pricelist.display_name; + }) + message = _.str.sprintf(_t("%s fiscal position(s) added to the configuration."), pricelistsNames.join(", ")); + return message; + } + async _addFiscalPositions(ordersJson) { + let fiscalPositionToGet = []; + ordersJson.forEach((order) => { + let found = false; + for (let fp of this.fiscal_positions) { + if (fp.id === order.fiscal_position_id) { + found = true; + break; + } + } + if (!found && order.fiscal_position_id) { + fiscalPositionToGet.push(order.fiscal_position_id); + } + }); + let message = null; + if (fiscalPositionToGet.length > 0) { + const fiscalPositionJson = await this._getFiscalPositionJson(fiscalPositionToGet); + message = this._addPosFiscalPosition(fiscalPositionJson); + } + return message; + } + async _getFiscalPositionJson(fiscalPositionToGet) { + return await this.env.services.orm.call("pos.session", "get_pos_ui_account_fiscal_positions_by_ids", [ + [odoo.pos_session_id], + fiscalPositionToGet, + ]); + } + _addPosFiscalPosition(fiscalPositionJson) { + this.fiscal_positions.push(...fiscalPositionJson); + let message = ""; + const fiscalPositionNames = fiscalPositionJson.map((fp) => { + return fp.display_name; + }) + message = _.str.sprintf(_t("%s fiscal position(s) added to the configuration."), fiscalPositionNames.join(", ")); + return message; + } + sortOrders() { + this.orders.sort((a, b) => (a.name > b.name) ? 1 : -1) + } async getProductInfo(product, quantity) { const order = this.get_order(); // check back-end method `get_product_info_pos` to see what it returns @@ -648,6 +880,9 @@ export class PosGlobalState extends PosModel { set_start_order() { if (this.orders.length && !this.selectedOrder) { this.selectedOrder = this.orders[0]; + if (this.isOpenOrderShareable()) { + this.ordersToUpdateSet.add(this.orders[0]); + } } else { this.add_new_order(); } @@ -1401,6 +1636,9 @@ export class PosGlobalState extends PosModel { this.db.update_partners(partnerWithUpdatedTotalDue); return partnerWithUpdatedTotalDue; } + isOpenOrderShareable() { + return this.config.trusted_config_ids.length > 0; + } } PosGlobalState.prototype.electronic_payment_interfaces = {}; @@ -1671,7 +1909,7 @@ export class Orderline extends PosModel { } catch { console.error( "ERROR: attempting to recover product ID", - options.json.product_id, + options.json.product_id[0], "not available in the point of sale. Correct the product or clean the browser cache." ); } @@ -2676,7 +2914,7 @@ export class Order extends PosModel { var orderlines = json.lines; for (var i = 0; i < orderlines.length; i++) { var orderline = orderlines[i][2]; - if (this.pos.db.get_product_by_id(orderline.product_id)) { + if (orderline.product_id && this.pos.db.get_product_by_id(orderline.product_id)) { this.add_orderline( new Orderline({}, { pos: this.pos, order: this, json: orderline }) ); diff --git a/addons/point_of_sale/static/src/scss/pos.scss b/addons/point_of_sale/static/src/scss/pos.scss index 67afa8191eb..c00a2a770e6 100644 --- a/addons/point_of_sale/static/src/scss/pos.scss +++ b/addons/point_of_sale/static/src/scss/pos.scss @@ -1313,11 +1313,18 @@ td { .screen .top-content .button.highlight:hover { background: rgb(120,210,165); } - +.screen .validation-buttons { + display: flex; + width: 100%; + align-items: flex-end; + justify-content: center; + gap: 2px; +} .screen .button.next.validation { position: sticky; bottom: 0; height: auto; + width: 100%; padding: map-get($spacers, 5) 0; } .screen .button.next:not(.highlight) { @@ -1726,9 +1733,8 @@ td { /* c) The receipt screen */ .pos .receipt-screen .screen-content { - display: grid; - grid-template-columns: 1fr; - grid-template-rows: auto 1fr auto; + display: flex; + justify-content: space-between; } .pos .receipt-screen .left-content { diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SaveButton.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SaveButton.xml new file mode 100644 index 00000000000..2b937f00ec0 --- /dev/null +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ControlButtons/SaveButton.xml @@ -0,0 +1,11 @@ + + + + +
+ + Save +
+
+ +
diff --git a/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/ReceiptScreen.xml b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/ReceiptScreen.xml index bfdbaa82237..26d25c32db0 100644 --- a/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/ReceiptScreen.xml +++ b/addons/point_of_sale/static/src/xml/Screens/ReceiptScreen/ReceiptScreen.xml @@ -40,12 +40,21 @@ - diff --git a/addons/point_of_sale/static/tests/tours/TicketScreen.tour.js b/addons/point_of_sale/static/tests/tours/TicketScreen.tour.js index c6414ea3625..8d0a8e0bdfe 100644 --- a/addons/point_of_sale/static/tests/tours/TicketScreen.tour.js +++ b/addons/point_of_sale/static/tests/tours/TicketScreen.tour.js @@ -68,8 +68,8 @@ ProductScreen.check.isShown(); // Check that the Paid filter will show the 2 synced orders. Chrome.do.clickTicketButton(); TicketScreen.do.selectFilter("Paid"); -TicketScreen.check.nthRowContains(2, "Brandon Freeman"); -TicketScreen.check.nthRowContains(3, "-0003"); +TicketScreen.check.nthRowContains(3, "Brandon Freeman"); +TicketScreen.check.nthRowContains(2, "-0003"); // Invoice order TicketScreen.do.selectOrder("-0003"); TicketScreen.check.orderWidgetIsNotEmpty(); diff --git a/addons/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js index 5aa24fb4dda..1d5928aa6e7 100644 --- a/addons/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js +++ b/addons/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js @@ -7,7 +7,7 @@ class Do { return [ { content: "go to next screen", - trigger: ".receipt-screen .button.next.highlight", + trigger: ".receipt-screen .button.next.highlight[name='done']", }, ]; } diff --git a/addons/point_of_sale/static/tests/tours/helpers/TicketScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/TicketScreenTourMethods.js index e19bac32957..ca4f6e0bd4f 100644 --- a/addons/point_of_sale/static/tests/tours/helpers/TicketScreenTourMethods.js +++ b/addons/point_of_sale/static/tests/tours/helpers/TicketScreenTourMethods.js @@ -130,6 +130,14 @@ class Check { }, ]; } + contains( string) { + return [ + { + trigger: `.ticket-screen .orders:contains("${string}")`, + run: () => {}, + }, + ]; + } noNewTicketButton() { return [ { diff --git a/addons/point_of_sale/views/res_config_settings_views.xml b/addons/point_of_sale/views/res_config_settings_views.xml index 9acdcbd2008..be88a5cdde0 100644 --- a/addons/point_of_sale/views/res_config_settings_views.xml +++ b/addons/point_of_sale/views/res_config_settings_views.xml @@ -79,6 +79,17 @@ + +
+
+
+
+
+
+
diff --git a/addons/pos_hr/models/pos_order.py b/addons/pos_hr/models/pos_order.py index f11c6c2e2d2..e7804d19f39 100644 --- a/addons/pos_hr/models/pos_order.py +++ b/addons/pos_hr/models/pos_order.py @@ -28,3 +28,17 @@ class PosOrder(models.Model): 'employee_id': order.employee_id.id, }) return result + + def _get_fields_for_draft_order(self): + fields = super(PosOrder, self)._get_fields_for_draft_order() + fields.append('employee_id') + return fields + + @api.model + def get_draft_share_order_ids(self, config_id): + orders = super(PosOrder, self).get_draft_share_order_ids(config_id) + for order in orders: + if order['employee_id']: + order['employee_id'] = order['employee_id'][0] + + return orders diff --git a/addons/pos_restaurant/data/pos_restaurant_demo.xml b/addons/pos_restaurant/data/pos_restaurant_demo.xml index 3720ad71347..7110bf249ef 100644 --- a/addons/pos_restaurant/data/pos_restaurant_demo.xml +++ b/addons/pos_restaurant/data/pos_restaurant_demo.xml @@ -210,7 +210,7 @@ Main Floor rgb(136,137,242) - + @@ -350,7 +350,7 @@ Patio rgb(130, 233, 171) - + diff --git a/addons/pos_restaurant/models/pos_config.py b/addons/pos_restaurant/models/pos_config.py index a757deea671..e21a4568414 100644 --- a/addons/pos_restaurant/models/pos_config.py +++ b/addons/pos_restaurant/models/pos_config.py @@ -10,7 +10,7 @@ class PosConfig(models.Model): iface_splitbill = fields.Boolean(string='Bill Splitting', help='Enables Bill Splitting in the Point of Sale.') iface_printbill = fields.Boolean(string='Bill Printing', help='Allows to print the Bill before payment.') iface_orderline_notes = fields.Boolean(string='Kitchen Notes', help='Allow custom kitchen notes on Orderlines.', default=True) - floor_ids = fields.One2many('restaurant.floor', 'pos_config_id', string='Restaurant Floors', help='The restaurant floors served by this point of sale.') + floor_ids = fields.Many2many('restaurant.floor', string='Restaurant Floors', help='The restaurant floors served by this point of sale.') printer_ids = fields.Many2many('restaurant.printer', 'pos_config_printer_rel', 'config_id', 'printer_id', string='Order Printers') is_table_management = fields.Boolean('Floors & Tables') is_order_printer = fields.Boolean('Order Printer') @@ -26,7 +26,7 @@ class PosConfig(models.Model): def get_tables_order_count(self): """ """ self.ensure_one() - tables = self.env['restaurant.table'].search([('floor_id.pos_config_id', 'in', self.ids)]) + tables = self.env['restaurant.table'].search([('floor_id.pos_config_ids', '=', self.id)]) domain = [('state', '=', 'draft'), ('table_id', 'in', tables.ids)] order_stats = self.env['pos.order'].read_group(domain, ['table_id'], 'table_id') diff --git a/addons/pos_restaurant/models/pos_order.py b/addons/pos_restaurant/models/pos_order.py index 6dfd4063544..aee1e4d4151 100644 --- a/addons/pos_restaurant/models/pos_order.py +++ b/addons/pos_restaurant/models/pos_order.py @@ -24,144 +24,23 @@ class PosOrder(models.Model): customer_count = fields.Integer(string='Guests', help='The amount of customers that have been served by this order.', readonly=True) multiprint_resume = fields.Char(string='Multiprint Resume', help="Last printed state of the order") - def _get_pack_lot_lines(self, order_lines): - """Add pack_lot_lines to the order_lines. - - The function doesn't return anything but adds the results directly to the order_lines. - - :param order_lines: order_lines for which the pack_lot_lines are to be requested. - :type order_lines: pos.order.line. - """ - pack_lots = self.env['pos.pack.operation.lot'].search_read( - domain = [('pos_order_line_id', 'in', [order_line['id'] for order_line in order_lines])], - fields = [ - 'id', - 'lot_name', - 'pos_order_line_id' - ]) - for pack_lot in pack_lots: - pack_lot['order_line'] = pack_lot['pos_order_line_id'][0] - pack_lot['server_id'] = pack_lot['id'] - - del pack_lot['pos_order_line_id'] - del pack_lot['id'] - - for order_line_id, pack_lot_ids in groupby(pack_lots, key=lambda x:x['order_line']): - next(order_line for order_line in order_lines if order_line['id'] == order_line_id)['pack_lot_ids'] = list(pack_lot_ids) - def _get_fields_for_order_line(self): fields = super(PosOrder, self)._get_fields_for_order_line() fields.extend([ - 'id', - 'discount', - 'product_id', - 'price_unit', - 'order_id', - 'qty', 'note', 'uuid', 'mp_skip', - 'full_product_name', - 'customer_note', - 'price_extra', ]) return fields - def _prepare_order_line(self, order_line): - """Method that will allow the cleaning of values to send the correct information. - :param order_line: order_line that will be cleaned. - :type order_line: pos.order.line. - :returns: dict -- dict representing the order line's values. - """ - order_line = super(PosOrder, self)._prepare_order_line(order_line) - order_line["product_id"] = order_line["product_id"][0] - order_line["server_id"] = order_line["id"] - - del order_line["id"] - if not "pack_lot_ids" in order_line: - order_line["pack_lot_ids"] = [] - else: - order_line["pack_lot_ids"] = [[0, 0, lot] for lot in order_line["pack_lot_ids"]] - return order_line - - def _get_order_lines(self, orders): - """Add pos_order_lines to the orders. - - The function doesn't return anything but adds the results directly to the orders. - - :param orders: orders for which the order_lines are to be requested. - :type orders: pos.order. - """ - order_lines = self.env['pos.order.line'].search_read( - domain = [('order_id', 'in', [to['id'] for to in orders])], - fields = self._get_fields_for_order_line()) - - if order_lines != []: - self._get_pack_lot_lines(order_lines) - - extended_order_lines = [] - for order_line in order_lines: - extended_order_lines.append([0, 0, self._prepare_order_line(order_line)]) - - for order_id, order_lines in groupby(extended_order_lines, key=lambda x:x[2]['order_id']): - next(order for order in orders if order['id'] == order_id[0])['lines'] = list(order_lines) - - def _get_fields_for_payment_lines(self): - return [ - 'id', - 'amount', - 'pos_order_id', - 'payment_method_id', - 'card_type', - 'cardholder_name', - 'transaction_id', - 'payment_status' - ] - - def _get_payments_lines_list(self, orders): - payment_lines = self.env['pos.payment'].search_read( - domain = [('pos_order_id', 'in', [po['id'] for po in orders])], - fields = self._get_fields_for_payment_lines()) - - extended_payment_lines = [] - for payment_line in payment_lines: - payment_line['server_id'] = payment_line['id'] - payment_line['payment_method_id'] = payment_line['payment_method_id'][0] - - del payment_line['id'] - extended_payment_lines.append([0, 0, payment_line]) - return extended_payment_lines - - def _get_payment_lines(self, orders): - """Add account_bank_statement_lines to the orders. - - The function doesn't return anything but adds the results directly to the orders. - - :param orders: orders for which the payment_lines are to be requested. - :type orders: pos.order. - """ - extended_payment_lines = self._get_payments_lines_list(orders) - for order_id, payment_lines in groupby(extended_payment_lines, key=lambda x:x[2]['pos_order_id']): - next(order for order in orders if order['id'] == order_id[0])['statement_ids'] = list(payment_lines) - def _get_fields_for_draft_order(self): - return [ - 'id', - 'pricelist_id', - 'partner_id', - 'sequence_number', - 'session_id', - 'pos_reference', - 'create_uid', - 'create_date', - 'customer_count', - 'fiscal_position_id', + fields = super()._get_fields_for_draft_order() + fields.extend([ 'table_id', - 'to_invoice', + 'customer_count', 'multiprint_resume', - 'access_token', - 'ticket_code', - ] + ]) + return fields def _get_domain_for_draft_orders(self, table_ids): """ Get the domain to search for draft orders on a table. @@ -189,48 +68,16 @@ class PosOrder(models.Model): self._get_order_lines(table_orders) self._get_payment_lines(table_orders) - for order in table_orders: - order['pos_session_id'] = order['session_id'][0] - order['uid'] = search(r"\d{5,}-\d{3,}-\d{4,}", order['pos_reference']).group(0) - order['name'] = order['pos_reference'] - order['creation_date'] = order['create_date'] - order['server_id'] = order['id'] - if order['fiscal_position_id']: - order['fiscal_position_id'] = order['fiscal_position_id'][0] - if order['pricelist_id']: - order['pricelist_id'] = order['pricelist_id'][0] - if order['partner_id']: - order['partner_id'] = order['partner_id'][0] - if order['table_id']: - order['table_id'] = order['table_id'][0] - - if not 'lines' in order: - order['lines'] = [] - if not 'statement_ids' in order: - order['statement_ids'] = [] - - del order['id'] - del order['session_id'] - del order['pos_reference'] - del order['create_date'] + self._prepare_order(table_orders) return table_orders @api.model - def remove_from_ui(self, server_ids): - """ Remove orders from the frontend PoS application - - Remove orders from the server by id. - :param server_ids: list of the id's of orders to remove from the server. - :type server_ids: list. - :returns: list -- list of db-ids for the removed orders. - """ - orders = self.search([('id', 'in', server_ids), ('state', '=', 'draft')]) - orders.write({'state': 'cancel'}) - # TODO Looks like delete cascade is a better solution. - orders.mapped('payment_ids').sudo().unlink() - orders.sudo().unlink() - return orders.ids + def _prepare_order(self, orders): + super(PosOrder, self)._prepare_order(orders) + for order in orders: + if order['table_id']: + order['table_id'] = order['table_id'][0] def set_tip(self, tip_line_vals): """Set tip to `self` based on values in `tip_line_vals`.""" diff --git a/addons/pos_restaurant/models/pos_restaurant.py b/addons/pos_restaurant/models/pos_restaurant.py index 6dc85e2c0b9..9c4d08a1ff8 100644 --- a/addons/pos_restaurant/models/pos_restaurant.py +++ b/addons/pos_restaurant/models/pos_restaurant.py @@ -12,7 +12,7 @@ class RestaurantFloor(models.Model): _order = "sequence, name" name = fields.Char('Floor Name', required=True) - pos_config_id = fields.Many2one('pos.config', string='Point of Sale') + pos_config_ids = fields.Many2many('pos.config', string='Point of Sales', domain="[('module_pos_restaurant', '=', True)]") background_image = fields.Binary('Background Image') background_color = fields.Char('Background Color', help='The background color of the floor in a html-compatible format', default='rgb(210, 210, 210)') table_ids = fields.One2many('restaurant.table', 'floor_id', string='Tables') @@ -21,7 +21,7 @@ class RestaurantFloor(models.Model): @api.ondelete(at_uninstall=False) def _unlink_except_active_pos_session(self): - confs = self.mapped('pos_config_id').filtered(lambda c: c.is_table_management == True) + confs = self.mapped('pos_config_ids').filtered(lambda c: c.is_table_management) opened_session = self.env['pos.session'].search([('config_id', 'in', confs.ids), ('state', '!=', 'closed')]) if opened_session: error_msg = _("You cannot remove a floor that is used in a PoS session, close the session(s) first: \n") @@ -34,12 +34,11 @@ class RestaurantFloor(models.Model): def write(self, vals): for floor in self: - if floor.pos_config_id.has_active_session and (vals.get('pos_config_id') or vals.get('active')) : - raise UserError( - 'Please close and validate the following open PoS Session before modifying this floor.\n' - 'Open session: %s' % (' '.join(floor.pos_config_id.mapped('name')),)) - if vals.get('pos_config_id') and floor.pos_config_id.id and vals.get('pos_config_id') != floor.pos_config_id.id: - raise UserError(_('The %s is already used in another Pos Config.', floor.name)) + for config in floor.pos_config_ids: + if config.has_active_session and (vals.get('pos_config_ids') or vals.get('active')): + raise UserError( + 'Please close and validate the following open PoS Session before modifying this floor.\n' + 'Open session: %s' % (' '.join(config.mapped('name')),)) return super(RestaurantFloor, self).write(vals) @@ -81,7 +80,7 @@ class RestaurantTable(models.Model): @api.ondelete(at_uninstall=False) def _unlink_except_active_pos_session(self): - confs = self.mapped('floor_id').mapped('pos_config_id').filtered(lambda c: c.is_table_management == True) + confs = self.mapped('floor_id').mapped('pos_config_ids').filtered(lambda c: c.is_table_management) opened_session = self.env['pos.session'].search([('config_id', 'in', confs.ids), ('state', '!=', 'closed')]) if opened_session: error_msg = _("You cannot remove a table that is used in a PoS session, close the session(s) first.") diff --git a/addons/pos_restaurant/models/pos_session.py b/addons/pos_restaurant/models/pos_session.py index eeb7ab2dac9..fc3dd3ce886 100644 --- a/addons/pos_restaurant/models/pos_session.py +++ b/addons/pos_restaurant/models/pos_session.py @@ -19,9 +19,8 @@ class PosSession(models.Model): def _loader_params_restaurant_floor(self): return { 'search_params': { - 'domain': [('pos_config_id', '=', self.config_id.id)], + 'domain': [('pos_config_ids', '=', self.config_id.id)], 'fields': ['name', 'background_color', 'table_ids', 'sequence'], - 'order': 'sequence', }, } diff --git a/addons/pos_restaurant/models/res_config_settings.py b/addons/pos_restaurant/models/res_config_settings.py index 69fc47f7034..c68db279d8c 100644 --- a/addons/pos_restaurant/models/res_config_settings.py +++ b/addons/pos_restaurant/models/res_config_settings.py @@ -6,10 +6,7 @@ from odoo import fields, models, api class ResConfigSettings(models.TransientModel): _inherit = 'res.config.settings' - def _get_floors_domain(self): - return ['|', ('pos_config_id', 'in', self.pos_config_id.ids), ('pos_config_id', '=', False)] - - pos_floor_ids = fields.One2many(related='pos_config_id.floor_ids', readonly=False, domain=lambda self: self._get_floors_domain()) + pos_floor_ids = fields.Many2many(related='pos_config_id.floor_ids', readonly=False) pos_iface_orderline_notes = fields.Boolean(compute='_compute_pos_module_pos_restaurant', store=True, readonly=False) 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) diff --git a/addons/pos_restaurant/static/src/app/navbar/navbar.js b/addons/pos_restaurant/static/src/app/navbar/navbar.js index 5f4d73791d0..7cf61e1bfc0 100644 --- a/addons/pos_restaurant/static/src/app/navbar/navbar.js +++ b/addons/pos_restaurant/static/src/app/navbar/navbar.js @@ -6,23 +6,6 @@ import { BackToFloorButton } from "./BackToFloorButton"; patch(Navbar.components, "pos_restaurant.Navbar components", { BackToFloorButton }); patch(Navbar.prototype, "pos_restaurant.Navbar", { - async onTicketButtonClick() { - if ( - this.env.pos.config.iface_floorplan && - !this.isTicketScreenShown && - !this.env.pos.table - ) { - try { - this.env.pos.setLoadingOrderState(true); - await this.env.pos._syncAllOrdersFromServer(); - } finally { - this.env.pos.setLoadingOrderState(false); - this.pos.showScreen("TicketScreen"); - } - } else { - return this._super(...arguments); - } - }, /** * If no table is set to pos, which means the current main screen * is floor screen, then the order count should be based on all the orders. @@ -36,4 +19,10 @@ patch(Navbar.prototype, "pos_restaurant.Navbar", { } return this._super(...arguments); }, + _shouldLoadOrders() { + return (this._super() || ( + this.env.pos.config.iface_floorplan && + !this.env.pos.table + )); + }, }); diff --git a/addons/pos_restaurant/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js b/addons/pos_restaurant/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js index fa3bfb13129..7f3457ee895 100644 --- a/addons/pos_restaurant/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js +++ b/addons/pos_restaurant/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js @@ -27,6 +27,13 @@ patch(ReceiptScreen.prototype, "pos_restaurant.ReceiptScreen", { this._super(...arguments); } }, + isResumeVisible() { + if (this.env.pos.config.iface_floorplan && + this.env.pos.table) { + return this.env.pos.getTableOrders(this.env.pos.table.id).length > 1; + } + return this._super(...arguments); + }, //@override get nextScreen() { if (this.env.pos.config.iface_floorplan) { diff --git a/addons/pos_restaurant/static/src/js/Screens/TicketScreen.js b/addons/pos_restaurant/static/src/js/Screens/TicketScreen.js index 4b5243746b9..8b8a1a764de 100644 --- a/addons/pos_restaurant/static/src/js/Screens/TicketScreen.js +++ b/addons/pos_restaurant/static/src/js/Screens/TicketScreen.js @@ -16,7 +16,9 @@ patch(TicketScreen.prototype, "pos_restaurant.TicketScreen", { }); }, getTable(order) { - return `${order.getTable().floor.name} (${order.getTable().name})`; + if (order.getTable()) { + return `${order.getTable().floor.name} (${order.getTable().name})`; + } }, //@override _getSearchFields() { diff --git a/addons/pos_restaurant/static/src/js/models.js b/addons/pos_restaurant/static/src/js/models.js index 8cd6fc2b470..171755f2e23 100644 --- a/addons/pos_restaurant/static/src/js/models.js +++ b/addons/pos_restaurant/static/src/js/models.js @@ -7,15 +7,11 @@ import { Printer } from "@point_of_sale/js/printers"; import { patch } from "@web/core/utils/patch"; const QWeb = core.qweb; -// const TIMEOUT = 7500; - patch(PosGlobalState.prototype, "pos_restaurant.PosGlobalState", { setup() { this._super(...arguments); this.orderToTransfer = null; // table transfer feature - this.ordersToUpdateSet = new Set(); // used to know which orders need to be sent to the back end when syncing this.transferredOrdersSet = new Set(); // used to know which orders has been transferred but not sent to the back end yet - this.loadingOrderState = false; // used to prevent orders fetched to be put in the update set during the reactive change this.printers_category_ids_set = new Set(); }, //@override @@ -30,25 +26,6 @@ patch(PosGlobalState.prototype, "pos_restaurant.PosGlobalState", { } }, //@override - _onReactiveOrderUpdated(order) { - this._super(...arguments); - if (this.config.iface_floorplan && !this.loadingOrderState) { - this.ordersToUpdateSet.add(order); - } - }, - //@override - removeOrder(order, removeFromServer = true) { - this._super(...arguments); - if (this.config.iface_floorplan && removeFromServer) { - if (this.ordersToUpdateSet.has(order)) { - this.ordersToUpdateSet.delete(order); - } - if (order.server_id && !order.finalized) { - this.db.set_order_to_remove_from_server(order); - } - } - }, - //@override async after_load_server_data() { var res = await this._super(...arguments); if (this.config.iface_floorplan) { @@ -64,12 +41,6 @@ patch(PosGlobalState.prototype, "pos_restaurant.PosGlobalState", { this._super(...arguments); } }, - //@override - add_new_order() { - const order = this._super(...arguments); - this.ordersToUpdateSet.add(order); - return order; - }, isInterfacePrinter() { return this.env.pos.config.iface_printers; }, @@ -90,12 +61,6 @@ patch(PosGlobalState.prototype, "pos_restaurant.PosGlobalState", { } return reactiveOrder; }, - //@override - async load_orders() { - this.loadingOrderState = true; - await this._super(...arguments); - this.loadingOrderState = false; - }, _loadRestaurantPrinter(printers) { this.unwatched.printers = []; // list of product categories that belong to one or more order printer @@ -146,54 +111,9 @@ patch(PosGlobalState.prototype, "pos_restaurant.PosGlobalState", { const ordersResponse = await this._save_to_server(ordersToSync, { draft: true }); const tableOrders = [...this.ordersToUpdateSet].map((order) => order); ordersResponse.forEach((orderResponseData) => - this._updateTableOrder(orderResponseData, tableOrders) + this._updateOrder(orderResponseData, tableOrders) ); }, - // created this hook for modularity - _updateTableOrder(ordersResponseData, tableOrders) { - const order = tableOrders.find((order) => order.name === ordersResponseData.pos_reference); - order.server_id = ordersResponseData.id; - return order; - }, - /** - * Remove the deleted orders from the backend. - * @throw error - */ - async _removeOrdersFromServer() { - const removedOrdersIds = this.db.get_ids_to_remove_from_server(); - if (removedOrdersIds.length === 0) { - return; - } - - this.set_synch("connecting", removedOrdersIds.length); - try { - // FIXME POSREF timeout - // const timeout = TIMEOUT * removedOrdersIds.length; - const removeOrdersResponseData = await this.env.services.orm.silent.call( - "pos.order", - "remove_from_ui", - [removedOrdersIds] - ); - this.set_synch("connected"); - this._postRemoveFromServer(removedOrdersIds, removeOrdersResponseData); - } catch (reason) { - const error = reason.message; - if (error.code === 200) { - // Business Logic Error, not a connection problem - //if warning do not need to display traceback!! - if (error.data.exception_type == "warning") { - delete error.data.debug; - } - } - // important to throw error here and let the rendering component handle the error - console.warn("Failed to remove orders:", removedOrdersIds); - throw error; - } - }, - // to override - _postRemoveFromServer(serverIds, data) { - this.db.set_ids_removed_from_server(serverIds); - }, /** * Replace all the orders of a table by orders fetched from the backend * @param tableId ID of the table @@ -202,46 +122,51 @@ patch(PosGlobalState.prototype, "pos_restaurant.PosGlobalState", { async _syncTableOrdersFromServer(tableId) { await this._removeOrdersFromServer(); // in case we were offline and we deleted orders in the mean time const ordersJsons = await this._getTableOrdersFromServer([tableId]); + await this._addPricelists(ordersJsons); + await this._addFiscalPositions(ordersJsons); const tableOrders = this.getTableOrders(tableId); this._replaceOrders(tableOrders, ordersJsons); }, - async _syncAllOrdersFromServer() { - await this._removeOrdersFromServer(); // in case we were offline and we deleted orders in the mean time - const tableIds = [].concat( - ...this.floors.map((floor) => floor.tables.map((table) => table.id)) - ); - const ordersJsons = await this._getTableOrdersFromServer(tableIds); // get all orders - await this._syncTableOrdersToServer(); // to prevent losing the transferred orders - const allOrders = [...this.get_order_list()]; - this._replaceOrders(allOrders, ordersJsons); - }, - _replaceOrders(ordersToReplace, newOrdersJsons) { - ordersToReplace.forEach((order) => { - // We don't remove the validated orders because we still want to see them in the ticket screen. - // Orders in 'ReceiptScreen' or 'TipScreen' are validated orders. - if (order.server_id && !order.finalized && !this.transferredOrdersSet.has(order)) { - this.removeOrder(order, false); - } - }); - newOrdersJsons.forEach((json) => { - // Because of the offline feature, some draft orders fetched from the backend will appear - // to belong in different table, but in fact they are already moved. - const transferredOrder = [...this.transferredOrdersSet].find( - (order) => order.uid === json.uid + async _getOrdersJson() { + if (this.config.iface_floorplan) { + const tableIds = [].concat( + ...this.floors.map((floor) => floor.tables.map((table) => table.id)) ); - const isSameTable = transferredOrder && transferredOrder.tableId === json.tableId; - if (isSameTable) { - // this means we transferred back to the original table, we'll prioritize the server state - this.removeOrder(transferredOrder, false); - } - if (!transferredOrder || isSameTable) { - const order = this.createReactiveOrder(json); - this.orders.add(order); - } - }); + await this._syncTableOrdersToServer(); // to prevent losing the transferred orders + const ordersJsons = await this._getTableOrdersFromServer(tableIds); // get all orders + return ordersJsons; + } else { + return await this._super(); + } }, - setLoadingOrderState(bool) { - this.loadingOrderState = bool; + _shouldRemoveOrder(order) { + return this._super(...arguments) && !this.transferredOrdersSet.has(order); + }, + _shouldCreateOrder(json) { + return (!this._transferredOrder(json) || this._isSameTable(json)) && (!this.selectedOrder || this._super(...arguments)); + }, + _shouldRemoveSelectedOrder(removeSelected) { + return this.selectedOrder && this._super(...arguments); + }, + _isSelectedOrder(json) { + return !this.selectedOrder || this._super(...arguments); + }, + _isSameTable(json) { + const transferredOrder = this._transferredOrder(json); + return transferredOrder && transferredOrder.tableId === json.tableId; + }, + _transferredOrder(json) { + return [...this.transferredOrdersSet].find( + (order) => order.uid === json.uid + ); + }, + _createOrder(json) { + const transferredOrder = this._transferredOrder(json) + if (this._isSameTable(json)) { + // this means we transferred back to the original table, we'll prioritize the server state + this.removeOrder(transferredOrder, false); + } + return this._super(...arguments); }, loadRestaurantFloor() { // we do this in the front end due to the circular/recursive reference needed @@ -314,6 +239,9 @@ patch(PosGlobalState.prototype, "pos_restaurant.PosGlobalState", { } return new Printer(url, this); }, + isOpenOrderShareable() { + return this._super(...arguments) || this.config.iface_floorplan; + }, }); // New orders are now associated with the current table, if any. @@ -321,7 +249,7 @@ patch(Order.prototype, "pos_restaurant.Order", { setup(options) { this._super(...arguments); if (this.pos.config.module_pos_restaurant) { - if (this.pos.config.iface_floorplan && !this.tableId && !options.json) { + if (this.pos.config.iface_floorplan && !this.tableId && !options.json && this.pos.table) { this.tableId = this.pos.table.id; } this.customerCount = this.customerCount || 1; @@ -372,7 +300,7 @@ patch(Order.prototype, "pos_restaurant.Order", { export_for_printing() { const json = this._super(...arguments); if (this.pos.config.module_pos_restaurant) { - if (this.pos.config.iface_floorplan) { + if (this.pos.config.iface_floorplan && this.getTable()) { json.table = this.getTable().name; } json.customer_count = this.getCustomerCount(); diff --git a/addons/pos_restaurant/static/tests/tours/TipScreen.tour.js b/addons/pos_restaurant/static/tests/tours/TipScreen.tour.js index dc21f22537e..14c398d921b 100644 --- a/addons/pos_restaurant/static/tests/tours/TipScreen.tour.js +++ b/addons/pos_restaurant/static/tests/tours/TipScreen.tour.js @@ -55,7 +55,7 @@ ProductScreen.check.totalAmountIs("8.0"); Chrome.do.backToFloor(); FloorScreen.check.orderCountSyncedInTableIs("5", "1"); Chrome.do.clickTicketButton(); -TicketScreen.check.nthRowContains("3", "Tipping"); +TicketScreen.check.nthRowContains("4", "Tipping"); // Tip 20% on order1 TicketScreen.do.selectOrder("-0001"); diff --git a/addons/pos_restaurant/tests/test_frontend.py b/addons/pos_restaurant/tests/test_frontend.py index e2ae18b879d..199834ed0e4 100644 --- a/addons/pos_restaurant/tests/test_frontend.py +++ b/addons/pos_restaurant/tests/test_frontend.py @@ -51,7 +51,7 @@ class TestFrontend(odoo.tests.HttpCase): main_floor = self.env['restaurant.floor'].create({ 'name': 'Main Floor', - 'pos_config_id': pos_config.id, + 'pos_config_ids': [(4, pos_config.id)], }) table_05 = self.env['restaurant.table'].create({ @@ -79,7 +79,7 @@ class TestFrontend(odoo.tests.HttpCase): second_floor = self.env['restaurant.floor'].create({ 'name': 'Second Floor', - 'pos_config_id': pos_config.id, + 'pos_config_ids': [(4, pos_config.id)], }) table_01 = self.env['restaurant.table'].create({ diff --git a/addons/pos_restaurant/views/pos_restaurant_views.xml b/addons/pos_restaurant/views/pos_restaurant_views.xml index f441906a9f5..1f994e7f700 100644 --- a/addons/pos_restaurant/views/pos_restaurant_views.xml +++ b/addons/pos_restaurant/views/pos_restaurant_views.xml @@ -12,7 +12,7 @@ - + @@ -34,7 +34,7 @@ - + @@ -56,12 +56,12 @@ - +
Floor Name:
-
Point of Sale:
+
Point of Sales: