From b0107e9230da5cb5c90819e5bee3e9cd323816ee Mon Sep 17 00:00:00 2001 From: Joseph Caburnay Date: Fri, 31 Jul 2020 09:36:39 +0000 Subject: [PATCH] [IMP] pos_restaurant,point_of_sale: set tip after payment 1. Introduce an option in restaurant config to allow setting tips after payment. 2. Tip form is shown in bills so the customers can choose/set their tips. 3. Validated orders are kept in TipScreen (access via TicketScreen) so that at the end of the day, the user/cashier can set the tips for each validated order. closes odoo/odoo#55488 Task-id: 2117029 Related: odoo/upgrade#1606 Signed-off-by: pimodoo --- addons/point_of_sale/models/pos_order.py | 6 + .../js/Screens/PaymentScreen/PaymentScreen.js | 5 +- .../js/Screens/TicketScreen/TicketScreen.js | 1 + addons/point_of_sale/static/src/js/models.js | 26 ++- .../tours/helpers/PaymentScreenTourMethods.js | 8 + addons/pos_restaurant/__manifest__.py | 1 + addons/pos_restaurant/models/__init__.py | 1 + addons/pos_restaurant/models/pos_config.py | 6 + addons/pos_restaurant/models/pos_order.py | 31 ++++ addons/pos_restaurant/models/pos_payment.py | 15 ++ .../static/src/css/restaurant.css | 156 ++++++++++++++++++ .../src/js/Screens/FloorScreen/FloorScreen.js | 4 +- .../static/src/js/Screens/PaymentScreen.js | 17 ++ .../js/Screens/ReceiptScreen/ReceiptScreen.js | 12 -- .../static/src/js/Screens/TipScreen.js | 80 +++++++++ addons/pos_restaurant/static/src/js/floors.js | 24 ++- .../static/src/xml/Screens/BillScreen.xml | 4 +- .../Screens/ReceiptScreen/OrderReceipt.xml | 44 +++++ .../static/src/xml/Screens/TipScreen.xml | 55 ++++++ .../static/tests/tours/TipScreen.tour.js | 113 +++++++++++++ .../tours/helpers/TipScreenTourMethods.js | 55 ++++++ addons/pos_restaurant/tests/test_frontend.py | 14 ++ .../pos_restaurant/views/pos_config_views.xml | 17 ++ .../views/pos_restaurant_templates.xml | 4 + 24 files changed, 673 insertions(+), 26 deletions(-) create mode 100644 addons/pos_restaurant/models/pos_payment.py create mode 100644 addons/pos_restaurant/static/src/js/Screens/PaymentScreen.js create mode 100644 addons/pos_restaurant/static/src/js/Screens/TipScreen.js create mode 100644 addons/pos_restaurant/static/src/xml/Screens/TipScreen.xml create mode 100644 addons/pos_restaurant/static/tests/tours/TipScreen.tour.js create mode 100644 addons/pos_restaurant/static/tests/tours/helpers/TipScreenTourMethods.js diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index ecdb7ae0e45..500890cf0fe 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -49,6 +49,8 @@ class PosOrder(models.Model): 'amount_return': ui_order['amount_return'], 'company_id': self.env['pos.session'].browse(ui_order['pos_session_id']).company_id.id, 'to_invoice': ui_order['to_invoice'] if "to_invoice" in ui_order else False, + 'is_tipped': ui_order.get('is_tipped', False), + 'tip_amount': ui_order.get('tip_amount', 0), } @api.model @@ -253,6 +255,8 @@ class PosOrder(models.Model): session_move_id = fields.Many2one('account.move', string='Session Journal Entry', related='session_id.move_id', readonly=True, copy=False) to_invoice = fields.Boolean('To invoice') is_invoiced = fields.Boolean('Is Invoiced', compute='_compute_is_invoiced') + is_tipped = fields.Boolean('Is this already tipped?', readonly=True) + tip_amount = fields.Float(string='Tip Amount', digits=0, readonly=True) @api.depends('account_move') def _compute_is_invoiced(self): @@ -615,6 +619,8 @@ class PosOrder(models.Model): 'state': order.state, 'account_move': order.account_move.id, 'id': order.id, + 'is_tipped': order.is_tipped, + 'tip_amount': order.tip_amount, } def export_for_ui(self): diff --git a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js index 2d552e4a1d4..900204dadb0 100644 --- a/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js +++ b/addons/point_of_sale/static/src/js/Screens/PaymentScreen/PaymentScreen.js @@ -199,7 +199,7 @@ odoo.define('point_of_sale.PaymentScreen', function (require) { const shouldShowPrintInvoice = errorCode ? this.currentOrder.is_to_invoice() && errorCode < 0 : false; - this.showScreen('ReceiptScreen', { printInvoiceIsShown: shouldShowPrintInvoice }); + this.showScreen(this.nextScreen, { printInvoiceIsShown: shouldShowPrintInvoice }); // If we succeeded in syncing the current order, and // there are still other orders that are left unsynced, @@ -219,6 +219,9 @@ odoo.define('point_of_sale.PaymentScreen', function (require) { } } } + get nextScreen() { + return 'ReceiptScreen'; + } async _isOrderValid(isForceValidate) { if (this.currentOrder.get_orderlines().length === 0) { this.showPopup('ErrorPopup', { 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 724766e93da..52ff376d36b 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 @@ -163,6 +163,7 @@ odoo.define('point_of_sale.TicketScreen', function (require) { ProductScreen: 'Ongoing', PaymentScreen: 'Payment', ReceiptScreen: 'Receipt', + TipScreen: 'Tipping', }; } _initializeSearchFieldConstants() { diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 6d0d5ff63f8..71d5453874d 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -70,6 +70,10 @@ exports.PosModel = Backbone.Model.extend({ this.order_sequence = 1; window.posmodel = this; + // Object mapping the order's name (which contains the uid) to it's server_id after + // validation (order paid then sent to the backend). + this.validated_orders_name_server_id_map = {}; + // Extract the config id from the url. var given_config = new RegExp('[\?&]config_id=([^&#]*)').exec(window.location.href); this.config_id = given_config && given_config[1] && parseInt(given_config[1]) || false; @@ -1035,6 +1039,9 @@ exports.PosModel = Backbone.Model.extend({ return this._save_to_server(orders, options).then(function (server_ids) { self.set_synch('connected'); + for (let i = 0; i < server_ids.length; i++) { + self.validated_orders_name_server_id_map[server_ids[i].pos_reference] = server_ids[i].id; + } return _.pluck(server_ids, 'id'); }).catch(function(error){ self.set_synch(self.get('failed') ? 'error' : 'disconnected'); @@ -1372,7 +1379,11 @@ exports.PosModel = Backbone.Model.extend({ disallowLineQuantityChange() { return false; - } + }, + + getCurrencySymbol() { + return this.currency ? this.currency.symbol : '$'; + }, }); /** @@ -2624,6 +2635,8 @@ exports.Order = Backbone.Model.extend({ this.account_move = json.account_move; this.backendId = json.id; this.isFromClosedSession = json.is_session_closed; + this.is_tipped = json.is_tipped || false; + this.tip_amount = json.tip_amount || 0; }, export_as_JSON: function() { var orderLines, paymentLines; @@ -2654,6 +2667,8 @@ exports.Order = Backbone.Model.extend({ fiscal_position_id: this.fiscal_position ? this.fiscal_position.id : false, server_id: this.server_id ? this.server_id : false, to_invoice: this.to_invoice ? this.to_invoice : false, + is_tipped: this.is_tipped || false, + tip_amount: this.tip_amount || 0, }; if (!this.is_paid && this.user_id) { json.user_id = this.user_id; @@ -2848,7 +2863,8 @@ exports.Order = Backbone.Model.extend({ return; } } - this.add_product(tip_product, { + return this.add_product(tip_product, { + is_tip: true, quantity: 1, price: tip, lst_price: tip, @@ -2921,6 +2937,10 @@ exports.Order = Backbone.Model.extend({ line[prop] = options.extras[prop]; } } + if (options.is_tip) { + this.is_tipped = true; + this.tip_amount = options.price; + } var to_merge_orderline; for (var i = 0; i < this.orderlines.length; i++) { @@ -3299,7 +3319,7 @@ exports.Order = Backbone.Model.extend({ if (this.get_paymentlines().length > 0) return { name: 'PaymentScreen' }; return { name: 'ProductScreen' }; } - if (screen.name !== 'ReceiptScreen' && this.get_paymentlines().length > 0) { + if (!this.finalized && this.get_paymentlines().length > 0) { return { name: 'PaymentScreen' }; } return screen; diff --git a/addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js index 8d65e98d63e..6950d01de5a 100644 --- a/addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js +++ b/addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js @@ -36,6 +36,14 @@ odoo.define('point_of_sale.tour.PaymentScreenTourMethods', function (require) { ]; } + clickTipButton() { + return [ + { + trigger: `.payment-buttons .js_tip`, + }, + ]; + } + clickInvoiceButton() { return [{ content: 'click invoice button', trigger: '.payment-buttons .js_invoice' }]; } diff --git a/addons/pos_restaurant/__manifest__.py b/addons/pos_restaurant/__manifest__.py index 6b5e14d4b0c..6d7ac74b47b 100644 --- a/addons/pos_restaurant/__manifest__.py +++ b/addons/pos_restaurant/__manifest__.py @@ -46,6 +46,7 @@ This module adds several features to the Point of Sale that are specific to rest 'static/src/xml/Screens/FloorScreen/EditBar.xml', 'static/src/xml/Screens/FloorScreen/TableWidget.xml', 'static/src/xml/Screens/FloorScreen/EditableTable.xml', + 'static/src/xml/Screens/TipScreen.xml', 'static/src/xml/ChromeWidgets/BackToFloorButton.xml', 'static/src/xml/multiprint.xml', ], diff --git a/addons/pos_restaurant/models/__init__.py b/addons/pos_restaurant/models/__init__.py index 2b2442fbddd..09ed911c296 100644 --- a/addons/pos_restaurant/models/__init__.py +++ b/addons/pos_restaurant/models/__init__.py @@ -3,4 +3,5 @@ from . import pos_config from . import pos_order +from . import pos_payment from . import pos_restaurant diff --git a/addons/pos_restaurant/models/pos_config.py b/addons/pos_restaurant/models/pos_config.py index c2047671b2c..04002463778 100644 --- a/addons/pos_restaurant/models/pos_config.py +++ b/addons/pos_restaurant/models/pos_config.py @@ -14,6 +14,7 @@ class PosConfig(models.Model): 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') + set_tip_after_payment = fields.Boolean('Set Tip After Payment') module_pos_restaurant = fields.Boolean(default=True) @api.onchange('module_pos_restaurant') @@ -25,6 +26,11 @@ class PosConfig(models.Model): 'is_table_management': False, 'iface_orderline_notes': False}) + @api.onchange('iface_tipproduct') + def _onchange_iface_tipproduct(self): + if not self.iface_tipproduct: + self.set_tip_after_payment = False + def get_tables_order_count(self): """ """ self.ensure_one() diff --git a/addons/pos_restaurant/models/pos_order.py b/addons/pos_restaurant/models/pos_order.py index 644c53be8e7..0f081830c20 100644 --- a/addons/pos_restaurant/models/pos_order.py +++ b/addons/pos_restaurant/models/pos_order.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from itertools import groupby from re import search +from functools import partial from odoo import api, fields, models @@ -182,6 +183,36 @@ class PosOrder(models.Model): return table_orders + def set_tip(self, tip_line_vals): + """Set tip to `self` based on values in `tip_line_vals`.""" + + self.ensure_one() + PosOrderLine = self.env['pos.order.line'] + process_line = partial(PosOrderLine._order_line_fields, session_id=self.session_id.id) + + # 1. add/modify tip orderline + processed_tip_line_vals = process_line([0, 0, tip_line_vals])[2] + processed_tip_line_vals.update({ "order_id": self.id }) + tip_line = self.lines.filtered(lambda line: line.product_id == self.session_id.config_id.tip_product_id) + if not tip_line: + tip_line = PosOrderLine.create(processed_tip_line_vals) + else: + tip_line.write(processed_tip_line_vals) + + # 2. modify payment + payment_line = self.payment_ids.filtered(lambda line: not line.is_change)[0] + # TODO it would be better to throw error if there are multiple payment lines + # then ask the user to select which payment to update, no? + payment_line._update_payment_line_for_tip(tip_line.price_subtotal_incl) + + # 3. flag order as tipped and update order fields + self.write({ + "is_tipped": True, + "tip_amount": tip_line.price_subtotal_incl, + "amount_total": self.amount_total + tip_line.price_subtotal_incl, + "amount_paid": self.amount_paid + tip_line.price_subtotal_incl, + }) + @api.model def _order_fields(self, ui_order): order_fields = super(PosOrder, self)._order_fields(ui_order) diff --git a/addons/pos_restaurant/models/pos_payment.py b/addons/pos_restaurant/models/pos_payment.py new file mode 100644 index 00000000000..d4bfae1572f --- /dev/null +++ b/addons/pos_restaurant/models/pos_payment.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class PosConfig(models.Model): + _inherit = 'pos.payment' + + def _update_payment_line_for_tip(self, tip_amount): + """Inherit this method to perform reauthorization or capture on electronic payment.""" + self.ensure_one() + self.write({ + "amount": self.amount + tip_amount, + }) diff --git a/addons/pos_restaurant/static/src/css/restaurant.css b/addons/pos_restaurant/static/src/css/restaurant.css index c7cb17e3643..cd4ec3043fd 100644 --- a/addons/pos_restaurant/static/src/css/restaurant.css +++ b/addons/pos_restaurant/static/src/css/restaurant.css @@ -391,3 +391,159 @@ flex: none; } } + +.tip-screen .tip-options .total-amount { + text-align: center; + font-size: x-large; + margin-top: 1.5rem; + margin-bottom: 1.5rem; +} + +.tip-screen .tip-amount-options { + background: white; + border-radius: 3px; + margin-left: 4rem; + margin-right: 4rem; +} + +.tip-screen .percentage-amounts { + display: flex; +} + +.tip-screen .tip-options .button { + background: #FAFAFA; + border: solid 1px rgb(209, 209, 209); + border-radius: 3px; + flex: 1; + margin: 1rem; + display: flex; + flex-flow: column nowrap; + justify-content: center; + cursor: pointer; +} + +.tip-screen .tip-options .button .percentage { + text-align: center; + font-size: xx-large; + color: #2196F3; + padding-top: 4rem; + padding-bottom: 0.75rem; +} + +.tip-screen .tip-options .button .amount { + text-align: center; + font-size: large; + color: #757575; + padding-bottom: 4rem; +} + +.tip-screen .custom-amount-form { + display: flex; +} + +.tip-screen .custom-amount-form .item { + font-size: x-large; + color: #2196F3; + margin: 1rem; + flex: 1 +} + +.tip-screen .custom-amount-form .label { + padding-top: 1.5rem; + padding-bottom: 1.5rem; + text-align: center; +} + +.tip-screen .custom-amount-form .input { + background: #FAFAFA; + border: solid 1px rgb(209, 209, 209); + border-radius: 3px; + margin: 1rem; + text-align: right; + overflow: hidden; + position: relative; +} + +.tip-screen .custom-amount-form .input .currency { + position: absolute; + display: flex; + right: 0; + top: 0; + bottom: 0; + width: 3rem; + justify-content: center; + align-items: center; +} + +.tip-screen .custom-amount-form .input input { + border: none; + padding: 0; + outline: none; + font-size: x-large; + text-align: right; + margin-left: 1rem; + margin-right: 3rem; + height: 100%; + width: calc(100% - 4rem); + background: #FAFAFA; + color: #2196F3; +} + +.tip-screen .custom-amount-form .input input:focus { + border: none; + padding: 0; + outline: none; +} + +.tip-screen .custom-amount-form .add { + text-align: center; +} + +.tip-screen .no-tip { + display: flex; + color: #2196F3; + font-size: x-large; +} + +.tip-screen .no-tip .button { + text-align: center; + flex: 1; + padding: 1.5rem; +} + +.pos-receipt .tip-form { + padding-top: 10px; + padding-bottom: 10px; +} + +.pos-receipt .tip-form > div { + margin-top: 10px; +} + +.pos-receipt .tip-form .checkbox { + display: inline-block; + height: 25px; + width: 25px; + border: solid 1px black; + margin-right: 10px; + box-shadow: 2px 2px 0px 0px rgba(171,171,171,1); +} + +.pos-receipt .tip-form .option { + display: flex; + flex-flow: row nowrap; + align-items: center; + flex: 1; + justify-content: center; +} + +.pos-receipt .tip-form .option .percentage { + font-size: 10px; + color: #757575; +} + +.pos-receipt .tip-form .percentage-options { + display: flex; + flex-flow: row nowrap; + margin-bottom: 16px; +} diff --git a/addons/pos_restaurant/static/src/js/Screens/FloorScreen/FloorScreen.js b/addons/pos_restaurant/static/src/js/Screens/FloorScreen/FloorScreen.js index 1bf06c838f5..1a4a1a2f14d 100644 --- a/addons/pos_restaurant/static/src/js/Screens/FloorScreen/FloorScreen.js +++ b/addons/pos_restaurant/static/src/js/Screens/FloorScreen/FloorScreen.js @@ -271,7 +271,9 @@ odoo.define('pos_restaurant.FloorScreen', function (require) { .filter( (o) => o.server_id === undefined && - (o.orderlines.length !== 0 || o.paymentlines.length !== 0) + (o.orderlines.length !== 0 || o.paymentlines.length !== 0) && + // do not count the orders that are already finalized + !o.finalized ).length; table_obj.order_count = table.orders + unsynced_orders; }); diff --git a/addons/pos_restaurant/static/src/js/Screens/PaymentScreen.js b/addons/pos_restaurant/static/src/js/Screens/PaymentScreen.js new file mode 100644 index 00000000000..f45d32ba6e2 --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Screens/PaymentScreen.js @@ -0,0 +1,17 @@ +odoo.define('pos_restaurant.PosResPaymentScreen', function (require) { + 'use strict'; + + const PaymentScreen = require('point_of_sale.PaymentScreen'); + const Registries = require('point_of_sale.Registries'); + + const PosResPaymentScreen = (PaymentScreen) => + class extends PaymentScreen { + get nextScreen() { + return this.env.pos.config.set_tip_after_payment && !this.currentOrder.is_tipped ? 'TipScreen' : super.nextScreen; + } + }; + + Registries.Component.extend(PaymentScreen, PosResPaymentScreen); + + return PosResPaymentScreen; +}); 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 5909292e0db..689c3bf40ca 100644 --- a/addons/pos_restaurant/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js +++ b/addons/pos_restaurant/static/src/js/Screens/ReceiptScreen/ReceiptScreen.js @@ -20,18 +20,6 @@ odoo.define('pos_restaurant.ReceiptScreen', function(require) { return super.nextScreen; } } - /** - * If this order is synced to/from the server, then the draft needs to be deleted - * after the order is paid and the receipt screen is exited via 'Next Order' button. - * Thus, we set this order to be removed from server. Note that only the draft version - * is deleted if we trace the algorithm. - */ - orderDone() { - if (this.env.pos.config.iface_floorplan) { - this.env.pos.db.set_order_to_remove_from_server(this.currentOrder); - } - super.orderDone(); - } }; Registries.Component.extend(ReceiptScreen, PosResReceiptScreen); diff --git a/addons/pos_restaurant/static/src/js/Screens/TipScreen.js b/addons/pos_restaurant/static/src/js/Screens/TipScreen.js new file mode 100644 index 00000000000..efbd83ea007 --- /dev/null +++ b/addons/pos_restaurant/static/src/js/Screens/TipScreen.js @@ -0,0 +1,80 @@ +odoo.define('pos_restaurant.TipScreen', function (require) { + 'use strict'; + + const Registries = require('point_of_sale.Registries'); + const PosComponent = require('point_of_sale.PosComponent'); + const { parse } = require('web.field_utils'); + const { useState } = owl.hooks; + + class TipScreen extends PosComponent { + constructor() { + super(...arguments); + this.state = useState({ inputCustomAmount: '' }); + this._totalAmount = this.currentOrder.get_total_with_tax(); + } + get totalAmount() { + return this._totalAmount; + } + get currentOrder() { + return this.env.pos.get_order(); + } + get percentageTips() { + return [ + { percentage: '15%', amount: 0.15 * this.totalAmount }, + { percentage: '20%', amount: 0.2 * this.totalAmount }, + { percentage: '25%', amount: 0.25 * this.totalAmount }, + ]; + } + async setTip(amount) { + const order = this.env.pos.get_order(); + const serverId = this.env.pos.validated_orders_name_server_id_map[order.name]; + + if (!serverId) { + this.showPopup('ErrorPopup', { + title: 'Unsynced order', + body: 'This order is not yet synced to server. Make sure it is synced then try again.', + }); + return; + } + + // set the tip by temporarily allowing order modification + order.finalized = false; + order.set_tip(amount); + order.finalized = true; + + // set_tip calls add_product which sets the new line as the selected_orderline + const tip_line = order.selected_orderline; + await this.rpc({ + method: 'set_tip', + model: 'pos.order', + args: [serverId, tip_line.export_as_JSON()], + }); + this.goNextScreen(); + } + async tipCustomAmount() { + const tipAmount = parse.float(this.state.inputCustomAmount); + if (tipAmount > 0.25 * this._totalAmount) { + const { confirmed } = await this.showPopup('ConfirmPopup', { + title: 'Are you sure?', + body: `${this.env.pos.format_currency( + tipAmount + )} is more than 25% of the order's total amount. Are you sure of this tip amount?`, + }); + if (!confirmed) return; + } + await this.setTip(tipAmount); + } + goNextScreen() { + this.env.pos.get_order().finalize(); + this.showScreen(this.nextScreen); + } + get nextScreen() { + return 'FloorScreen'; + } + } + TipScreen.template = 'pos_restaurant.TipScreen'; + + Registries.Component.add(TipScreen); + + return TipScreen; +}); diff --git a/addons/pos_restaurant/static/src/js/floors.js b/addons/pos_restaurant/static/src/js/floors.js index 51ded242c63..807176d5fbc 100644 --- a/addons/pos_restaurant/static/src/js/floors.js +++ b/addons/pos_restaurant/static/src/js/floors.js @@ -201,7 +201,17 @@ models.PosModel = models.PosModel.extend({ var orders = this.get_order_list(); if (orders.length) { order = order ? orders.find((o) => o.uid === order.uid) : null; - this.set_order(order || orders[0]); + if (order) { + this.set_order(order); + } else { + // do not mindlessly set the first order in the list. + orders = orders.filter(order => !order.finalized); + if (orders.length) { + this.set_order(orders[0]); + } else { + this.add_new_order(); + } + } } else { this.add_new_order(); // or create a new order with the current table } @@ -215,12 +225,10 @@ models.PosModel = models.PosModel.extend({ this._get_from_server(table.id).then(function (server_orders) { var orders = self.get_order_list(); orders.forEach(function(order){ - const screen = order.get_screen_data(); - // Only remove the 'Receipt' status order when 'Next Order' - // from the receipt screen is clicked. We are bypassing the - // removal of paid order here to make sure those orders are - // still in the tickets list. - if (order.server_id && screen.name !== 'ReceiptScreen'){ + // 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){ self.get("orders").remove(order); order.destroy(); } @@ -339,7 +347,7 @@ models.PosModel = models.PosModel.extend({ // get customer count at table get_customer_count: function(table) { - var orders = this.get_table_orders(table); + var orders = this.get_table_orders(table).filter(order => !order.finalized); var count = 0; for (var i = 0; i < orders.length; i++) { count += orders[i].get_customer_count(); diff --git a/addons/pos_restaurant/static/src/xml/Screens/BillScreen.xml b/addons/pos_restaurant/static/src/xml/Screens/BillScreen.xml index 0c904ece232..34fe66e36ec 100644 --- a/addons/pos_restaurant/static/src/xml/Screens/BillScreen.xml +++ b/addons/pos_restaurant/static/src/xml/Screens/BillScreen.xml @@ -10,7 +10,9 @@ Back -

Bill Printing

+
+

Bill Printing

+
Ok diff --git a/addons/pos_restaurant/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml b/addons/pos_restaurant/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml index 8d195f6cca2..e8f129507e7 100644 --- a/addons/pos_restaurant/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml +++ b/addons/pos_restaurant/static/src/xml/Screens/ReceiptScreen/OrderReceipt.xml @@ -10,6 +10,50 @@ !props.isBill + + +
+
Add a tip:
+
+
+
+
+
+ +
+
(15%)
+
+
+
+
+
+
+ +
+
(20%)
+
+
+
+
+
+
+ +
+
(25%)
+
+
+
+
+ Custom amount: +
________________________
+
+
+ Signature: +
________________________
+
+
+
+
diff --git a/addons/pos_restaurant/static/src/xml/Screens/TipScreen.xml b/addons/pos_restaurant/static/src/xml/Screens/TipScreen.xml new file mode 100644 index 00000000000..467292a7646 --- /dev/null +++ b/addons/pos_restaurant/static/src/xml/Screens/TipScreen.xml @@ -0,0 +1,55 @@ + + + + +
+
+
+ + + + Back + +
+

Add a tip

+
+
+
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
Custom Amount
+
+ +
+ +
+
+
+ Add +
+
+
+
No Tip
+
+
+
+
+
+
+ +
diff --git a/addons/pos_restaurant/static/tests/tours/TipScreen.tour.js b/addons/pos_restaurant/static/tests/tours/TipScreen.tour.js new file mode 100644 index 00000000000..05c7532d83d --- /dev/null +++ b/addons/pos_restaurant/static/tests/tours/TipScreen.tour.js @@ -0,0 +1,113 @@ +odoo.define('pos_restaurant.tour.TipScreen', function (require) { + 'use strict'; + + const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods'); + const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods'); + const { ReceiptScreen } = require('point_of_sale.tour.ReceiptScreenTourMethods'); + const { FloorScreen } = require('pos_restaurant.tour.FloorScreenTourMethods'); + const { TicketScreen } = require('point_of_sale.tour.TicketScreenTourMethods'); + const { TipScreen } = require('pos_restaurant.tour.TipScreenTourMethods'); + const { NumberPopup } = require('point_of_sale.tour.NumberPopupTourMethods'); + const { Chrome } = require('pos_restaurant.tour.ChromeTourMethods'); + const { getSteps, startSteps } = require('point_of_sale.tour.utils'); + var Tour = require('web_tour.tour'); + + startSteps(); + + // Create order that is synced when draft. + // order 1 + FloorScreen.do.clickTable('T2'); + ProductScreen.exec.addOrderline('Minute Maid', '1', '2'); + ProductScreen.check.totalAmountIs('2.0'); + Chrome.do.backToFloor(); + FloorScreen.check.orderCountSyncedInTableIs('T2', '1'); + FloorScreen.do.clickTable('T2'); + ProductScreen.check.totalAmountIs('2.0'); + ProductScreen.do.clickPayButton(); + PaymentScreen.do.clickPaymentMethod('Cash'); + PaymentScreen.do.clickValidate(); + TipScreen.check.isShown(); + Chrome.do.clickTicketButton(); + TicketScreen.do.clickNewTicket(); + // order 2 + ProductScreen.exec.addOrderline('Coca-Cola', '2', '2'); + ProductScreen.check.totalAmountIs('4.0'); + Chrome.do.backToFloor(); + FloorScreen.check.orderCountSyncedInTableIs('T2', '1'); + Chrome.do.clickTicketButton(); + TicketScreen.check.nthRowContains('2', 'Tipping'); + TicketScreen.do.clickDiscard(); + + // Create without syncing the draft. + // order 3 + FloorScreen.do.clickTable('T5'); + ProductScreen.exec.addOrderline('Minute Maid', '3', '2'); + ProductScreen.check.totalAmountIs('6.0'); + ProductScreen.do.clickPayButton(); + PaymentScreen.do.clickPaymentMethod('Cash'); + PaymentScreen.do.clickValidate(); + TipScreen.check.isShown(); + Chrome.do.clickTicketButton(); + TicketScreen.do.clickNewTicket(); + // order 4 + ProductScreen.exec.addOrderline('Coca-Cola', '4', '2'); + ProductScreen.check.totalAmountIs('8.0'); + Chrome.do.backToFloor(); + FloorScreen.check.orderCountSyncedInTableIs('T5', '1'); + Chrome.do.clickTicketButton(); + TicketScreen.check.nthRowContains('4', 'Tipping'); + + // Tip 20% on order1 + TicketScreen.do.selectOrder('-0001'); + TipScreen.check.isShown(); + TipScreen.check.totalAmountIs('2.0'); + TipScreen.check.percentAmountIs('15%', '0.30'); + TipScreen.check.percentAmountIs('20%', '0.40'); + TipScreen.check.percentAmountIs('25%', '0.50'); + TipScreen.do.clickPercentTip('20%'); + FloorScreen.check.isShown(); + Chrome.do.clickTicketButton(); + + // Tip 25% on order3 + TicketScreen.do.selectOrder('-0003'); + TipScreen.check.isShown(); + TipScreen.check.totalAmountIs('6.0'); + TipScreen.check.percentAmountIs('15%', '0.90'); + TipScreen.check.percentAmountIs('20%', '1.20'); + TipScreen.check.percentAmountIs('25%', '1.50'); + TipScreen.do.clickPercentTip('25%'); + FloorScreen.check.isShown(); + Chrome.do.clickTicketButton(); + + // finalize order 4 then tip custom amount + TicketScreen.do.selectOrder('-0004'); + ProductScreen.check.totalAmountIs('8.0'); + ProductScreen.do.clickPayButton(); + PaymentScreen.do.clickPaymentMethod('Cash'); + PaymentScreen.do.clickValidate(); + TipScreen.check.isShown(); + TipScreen.check.totalAmountIs('8.0'); + TipScreen.check.percentAmountIs('15%', '1.20'); + TipScreen.check.percentAmountIs('20%', '1.60'); + TipScreen.check.percentAmountIs('25%', '2.00'); + TipScreen.do.setCustomTip('1.00'); + FloorScreen.check.isShown(); + + + // tip order2 during payment + // tip screen should not show after validating payment screen + Chrome.do.clickTicketButton(); + TicketScreen.check.checkStatus('-0002', 'Ongoing'); + TicketScreen.do.selectOrder('-0002'); + ProductScreen.do.clickPayButton(); + PaymentScreen.do.clickTipButton(); + NumberPopup.check.isShown(); + NumberPopup.do.pressNumpad('1'); + NumberPopup.check.inputShownIs('1'); + NumberPopup.do.clickConfirm(); + PaymentScreen.do.clickPaymentMethod('Cash'); + PaymentScreen.do.clickValidate(); + ReceiptScreen.check.isShown(); + + Tour.register('PosResTipScreenTour', { test: true, url: '/pos/web' }, getSteps()); +}); diff --git a/addons/pos_restaurant/static/tests/tours/helpers/TipScreenTourMethods.js b/addons/pos_restaurant/static/tests/tours/helpers/TipScreenTourMethods.js new file mode 100644 index 00000000000..eb4104d4af8 --- /dev/null +++ b/addons/pos_restaurant/static/tests/tours/helpers/TipScreenTourMethods.js @@ -0,0 +1,55 @@ +odoo.define('pos_restaurant.tour.TipScreenTourMethods', function (require) { + 'use strict'; + + const { createTourMethods } = require('point_of_sale.tour.utils'); + + class Do { + clickPercentTip(percent) { + return [ + { + trigger: `.tip-screen .percentage:contains("${percent}")`, + }, + ]; + } + setCustomTip(amount) { + return [ + { + trigger: `.tip-screen .custom-amount-form input`, + run: `text ${amount}`, + }, + { + trigger: '.tip-screen .custom-amount-form .button', + }, + ]; + } + } + + class Check { + isShown() { + return [ + { + trigger: '.pos .tip-screen', + run: () => {}, + }, + ]; + } + totalAmountIs(amount) { + return [ + { + trigger: `.tip-screen .total-amount:contains("${amount}")`, + run: () => {}, + }, + ]; + } + percentAmountIs(percent, amount) { + return [ + { + trigger: `.tip-screen .percentage:contains("${percent}") ~ .amount:contains("${amount}")`, + run: () => {}, + }, + ]; + } + } + + return createTourMethods('TipScreen', Do, Check); +}); diff --git a/addons/pos_restaurant/tests/test_frontend.py b/addons/pos_restaurant/tests/test_frontend.py index 2f30f2f2d49..f13cec07767 100644 --- a/addons/pos_restaurant/tests/test_frontend.py +++ b/addons/pos_restaurant/tests/test_frontend.py @@ -183,3 +183,17 @@ class TestFrontend(odoo.tests.HttpCase): def test_04_ticket_screen(self): self.pos_config.with_user(self.env.ref('base.user_admin')).open_session_cb(check_coa=False) self.start_tour("/pos/web?config_id=%d" % self.pos_config.id, 'PosResTicketScreenTour', login="admin", step_delay=50) + + def test_05_tip_screen(self): + self.pos_config.write({'set_tip_after_payment': True, 'iface_tipproduct': True, 'tip_product_id': self.env.ref('point_of_sale.product_product_tip')}) + self.pos_config.with_user(self.env.ref('base.user_admin')).open_session_cb(check_coa=False) + self.start_tour("/pos/web?config_id=%d" % self.pos_config.id, 'PosResTipScreenTour', login="admin", step_delay=50) + + order1 = self.env['pos.order'].search([('pos_reference', 'ilike', '%-0001')]) + order2 = self.env['pos.order'].search([('pos_reference', 'ilike', '%-0002')]) + order3 = self.env['pos.order'].search([('pos_reference', 'ilike', '%-0003')]) + order4 = self.env['pos.order'].search([('pos_reference', 'ilike', '%-0004')]) + self.assertTrue(order1.is_tipped and order1.tip_amount == 0.40) + self.assertTrue(order2.is_tipped and order2.tip_amount == 1.00) + self.assertTrue(order3.is_tipped and order3.tip_amount == 1.50) + self.assertTrue(order4.is_tipped and order4.tip_amount == 1.00) diff --git a/addons/pos_restaurant/views/pos_config_views.xml b/addons/pos_restaurant/views/pos_config_views.xml index 14fd1f2e5d7..b9c32ef0322 100644 --- a/addons/pos_restaurant/views/pos_config_views.xml +++ b/addons/pos_restaurant/views/pos_config_views.xml @@ -62,6 +62,23 @@ +
+
+
+ +
+
+
+
+
+ + @@ -50,12 +52,14 @@ + +