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: