diff options
22 files changed, 1029 insertions, 145 deletions
diff --git a/addons/point_of_sale/controllers/main.py b/addons/point_of_sale/controllers/main.py index 833b6e32c1c..9b359da4db7 100644 --- a/addons/point_of_sale/controllers/main.py +++ b/addons/point_of_sale/controllers/main.py @@ -5,6 +5,7 @@ import werkzeug.utils from odoo import http from odoo.http import request +from odoo.osv.expression import AND _logger = logging.getLogger(__name__) @@ -12,18 +13,31 @@ _logger = logging.getLogger(__name__) class PosController(http.Controller): @http.route('/pos/web', type='http', auth='user') - def pos_web(self, **k): - # if user not logged in, log him in - pos_sessions = request.env['pos.session'].sudo().search([ - ('state', '=', 'opened'), - ('user_id', '=', request.session.uid), - ('rescue', '=', False)]) - if not pos_sessions: + def pos_web(self, config_id=False, **k): + """Open a pos session for the given config. + + The right pos session will be selected to open, if non is open yet a new session will be created. + + :param debug: The debug mode to load the session in. + :type debug: str. + :param config_id: id of the config that has to be loaded. + :type config_id: str. + :returns: object -- The rendered pos session. + """ + domain = [ + ('state', '=', 'opened'), + ('user_id', '=', request.session.uid), + ('rescue', '=', False) + ] + if config_id: + AND([domain,['config_id', '=', int(config_id)]]) + pos_session = request.env['pos.session'].sudo().search(domain, limit=1) + if not pos_session: return werkzeug.utils.redirect('/web#action=point_of_sale.action_client_pos_menu') - pos_sessions.login() + pos_session.login() # The POS only work in one company, so we enforce the one of the session in the context session_info = request.env['ir.http'].session_info() - session_info['user_context']['allowed_company_ids'] = pos_sessions.company_id.ids + session_info['user_context']['allowed_company_ids'] = pos_session.company_id.ids context = { 'session_info': session_info } diff --git a/addons/point_of_sale/models/pos_config.py b/addons/point_of_sale/models/pos_config.py index 0ba6baa7d1e..f247a2fecbb 100644 --- a/addons/point_of_sale/models/pos_config.py +++ b/addons/point_of_sale/models/pos_config.py @@ -226,10 +226,13 @@ class PosConfig(models.Model): else: pos_config.currency_id = pos_config.company_id.currency_id.id - @api.depends('session_ids') + @api.depends('session_ids', 'session_ids.state') def _compute_current_session(self): + """If there is an open session, store it to current_session_id / current_session_State. + """ for pos_config in self: - session = pos_config.session_ids.filtered(lambda s: s.state != 'closed' and not s.rescue) + session = pos_config.session_ids.filtered(lambda s: s.user_id.id == self.env.uid and \ + not s.state == 'closed' and not s.rescue) # sessions ordered by id desc pos_config.current_session_id = session and session[0].id or False pos_config.current_session_state = session and session[0].state or False @@ -467,13 +470,19 @@ class PosConfig(models.Model): # Methods to open the POS def open_ui(self): - """ open the pos interface """ + """Open the pos interface with config_id as an extra argument. + + In vanilla PoS each user can only have one active session, therefore it was not needed to pass the config_id + on opening a session. It is also possible to login to sessions created by other users. + + :returns: dict + """ self.ensure_one() # check all constraints, raises if any is not met self._validate_fields(set(self._fields) - {"cash_control"}) return { 'type': 'ir.actions.act_url', - 'url': '/pos/web/', + 'url': '/pos/web?config_id=%d' % self.id, 'target': 'self', } @@ -495,7 +504,6 @@ class PosConfig(models.Model): }) if self.current_session_id.state == 'opened': return self.open_ui() - return self._open_session(self.current_session_id.id) return self._open_session(self.current_session_id.id) def open_existing_session_cb(self): diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index 49ffd4ecb68..de36a06468d 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -48,6 +48,7 @@ class PosOrder(models.Model): 'amount_tax': ui_order['amount_tax'], '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, } @api.model @@ -95,22 +96,77 @@ class PosOrder(models.Model): return new_session + @api.model - def _process_order(self, pos_order): - pos_session = self.env['pos.session'].browse(pos_order['pos_session_id']) + def _process_order(self, order, draft, existing_order): + """Create or update an pos.order from a given dictionary. + + :param pos_order: dictionary representing the order. + :type pos_order: dict. + :param draft: Indicate that the pos_order is not validated yet. + :type draft: bool. + :param existing_order: order to be updated or False. + :type existing_order: pos.order. + :returns number pos_order id + """ + to_invoice = order['to_invoice'] if not draft else False + order = order['data'] + pos_session = self.env['pos.session'].browse(order['pos_session_id']) if pos_session.state == 'closing_control' or pos_session.state == 'closed': - pos_order['pos_session_id'] = self._get_valid_session(pos_order).id - order = self.create(self._order_fields(pos_order)) + order['pos_session_id'] = self._get_valid_session(order).id + + pos_order = False + if not existing_order: + pos_order = self.create(self._order_fields(order)) + else: + pos_order = existing_order + pos_order.lines.unlink() + order['user_id'] = pos_order.user_id.id + pos_order.write(self._order_fields(order)) + + self._process_payment_lines(order, pos_order, pos_session, draft) + + if not draft: + try: + pos_order.action_pos_order_paid() + except psycopg2.DatabaseError: + # do not hide transactional errors, the order(s) won't be saved! + raise + except Exception as e: + _logger.error('Could not fully process the POS Order: %s', tools.ustr(e)) + + if to_invoice: + pos_order.action_pos_order_invoice() + pos_order.account_move.sudo().with_context(force_company=self.env.user.company_id.id).post() + + return pos_order.id + + + def _process_payment_lines(self, pos_order, order, pos_session, draft): + """Create account.bank.statement.lines from the dictionary given to the parent function. + + If the payment_line is an updated version of an existing one, the existing payment_line will first be + removed before making a new one. + :param pos_order: dictionary representing the order. + :type pos_order: dict. + :param order: Order object the payment lines should belong to. + :type order: pos.order + :param pos_session: PoS session the order was created in. + :type pos_session: pos.session + :param draft: Indicate that the pos_order is not validated yet. + :type draft: bool. + """ prec_acc = order.pricelist_id.currency_id.decimal_places + + order_bank_statement_lines= self.env['pos.payment'].search([('pos_order_id', '=', order.id)]) + order_bank_statement_lines.unlink() for payments in pos_order['statement_ids']: if not float_is_zero(payments[2]['amount'], precision_digits=prec_acc): order.add_payment(self._payment_fields(order, payments[2])) - if pos_session.sequence_number <= pos_order['sequence_number']: - pos_session.write({'sequence_number': pos_order['sequence_number'] + 1}) - pos_session.refresh() + order.amount_paid = sum(order.payment_ids.mapped('amount')) - if not float_is_zero(pos_order['amount_return'], prec_acc): + if not draft and not float_is_zero(pos_order['amount_return'], prec_acc): cash_payment_method = pos_session.payment_method_ids.filtered('is_cash_count')[:1] if not cash_payment_method: raise UserError(_("No cash statement found for this session. Unable to record returned cash.")) @@ -122,7 +178,6 @@ class PosOrder(models.Model): 'payment_method_id': cash_payment_method.id, } order.add_payment(return_payment_vals) - return order def _prepare_invoice_line(self, order_line): return { @@ -195,18 +250,16 @@ class PosOrder(models.Model): ) payment_ids = fields.One2many('pos.payment', 'pos_order_id', string='Payments', readonly=True) 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') - @api.depends('payment_ids', 'payment_ids.amount') - def _compute_amount_paid(self): - for order in self: - order.amount_paid = sum(order.payment_ids.mapped('amount')) @api.depends('account_move') def _compute_is_invoiced(self): for order in self: order.is_invoiced = bool(order.account_move) + @api.depends('date_order', 'company_id', 'currency_id', 'company_id.currency_id') def _compute_currency_rate(self): for order in self: @@ -343,34 +396,27 @@ class PosOrder(models.Model): return self.write({'state': 'cancel'}) @api.model - def create_from_ui(self, orders): - # Keep only new orders - submitted_references = [o['data']['name'] for o in orders] - pos_order = self.search([('pos_reference', 'in', submitted_references)]) - existing_orders = pos_order.read(['pos_reference']) - existing_references = set([o['pos_reference'] for o in existing_orders]) - orders_to_save = [o for o in orders if o['data']['name'] not in existing_references] + def create_from_ui(self, orders, draft=False): + """ Create and update Orders from the frontend PoS application. + + Create new orders and update orders that are in draft status. If an order already exists with a status + diferent from 'draft'it will be discareded, otherwise it will be saved to the database. If saved with + 'draft' status the order can be overwritten later by this function. + + :param orders: dictionary with the orders to be created. + :type orders: dict. + :param draft: Indicate if the orders are ment to be finalised or temporarily saved. + :type draft: bool. + :Returns: list -- list of db-ids for the created and updated orders. + """ order_ids = [] + for order in orders: + existing_order = False + if 'server_id' in order['data']: + existing_order = self.env['pos.order'].search([('id', '=', order['data']['server_id'])], limit=1) + order_ids.append(self._process_order(order, draft, existing_order)) - for tmp_order in orders_to_save: - to_invoice = tmp_order['to_invoice'] - order = tmp_order['data'] - - pos_order = self._process_order(order) - order_ids.append(pos_order.id) - - try: - pos_order.action_pos_order_paid() - except psycopg2.DatabaseError: - # do not hide transactional errors, the order(s) won't be saved! - raise - except Exception as e: - _logger.error('Could not fully process the POS Order: %s', tools.ustr(e)) - - if to_invoice: - pos_order.action_pos_order_invoice() - pos_order.account_move.sudo().with_context(force_company=self.env.user.company_id.id).post() - return order_ids + return self.env['pos.order'].search_read(domain = [('id', 'in', order_ids)], fields = ['id', 'pos_reference']) def create_picking(self): """Create a picking for each order and validate it.""" @@ -516,7 +562,7 @@ class PosOrder(models.Model): """Create a new payment for the order""" self.ensure_one() self.env['pos.payment'].create(data) - self.amount_paid = sum(payment.amount for payment in self.payment_ids) + self.amount_paid = sum(self.payment_ids.mapped('amount')) def refund(self): """Create a copy of order for refund order""" @@ -558,6 +604,19 @@ class PosOrder(models.Model): 'target': 'current', } + @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'}) + orders.sudo().unlink() + return orders.ids class PosOrderLine(models.Model): _name = "pos.order.line" @@ -642,6 +701,15 @@ class PosOrderLine(models.Model): values['name'] = self.env['ir.sequence'].next_by_code('pos.order.line') return super(PosOrderLine, self).create(values) + @api.model + def write(self, values): + if values.get('pack_lot_line_ids'): + for pl in values.get('pack_lot_ids'): + if pl[2].get('server_id'): + pl[2]['id'] = pl[2]['server_id'] + del pl[2]['server_id'] + return super().write(values) + @api.onchange('price_unit', 'tax_ids', 'qty', 'discount', 'product_id') def _onchange_amount_line_all(self): for line in self: diff --git a/addons/point_of_sale/models/pos_session.py b/addons/point_of_sale/models/pos_session.py index 0f200cf971d..0ff8e78550b 100644 --- a/addons/point_of_sale/models/pos_session.py +++ b/addons/point_of_sale/models/pos_session.py @@ -153,16 +153,6 @@ class PosSession(models.Model): session.cash_register_id = statement.id break # stop iteration after finding the cash journal - @api.constrains('user_id', 'state') - def _check_unicity(self): - # open if there is no session in 'opening_control', 'opened', 'closing_control' for one user - if self.search_count([ - ('state', 'not in', ('closed', 'closing_control')), - ('user_id', '=', self.user_id.id), - ('rescue', '=', False) - ]) > 1: - raise ValidationError(_("You cannot create two active sessions with the same responsible.")) - @api.constrains('config_id') def _check_pos_config(self): if self.search_count([ @@ -754,15 +744,19 @@ class PosSession(models.Model): } def open_frontend_cb(self): + """Open the pos interface with config_id as an extra argument. + + In vanilla PoS each user can only have one active session, therefore it was not needed to pass the config_id + on opening a session. It is also possible to login to sessions created by other users. + + :returns: dict + """ if not self.ids: return {} - for session in self.filtered(lambda s: s.user_id.id != self.env.uid): - raise UserError(_("You cannot use the session of another user. This session is owned by %s. " - "Please first close this one to use this point of sale.") % session.user_id.name) return { 'type': 'ir.actions.act_url', 'target': 'self', - 'url': '/pos/web/', + 'url': '/pos/web?config_id=%d' % self.config_id.id, } def open_cashbox_pos(self): diff --git a/addons/point_of_sale/static/src/js/chrome.js b/addons/point_of_sale/static/src/js/chrome.js index 436b54cea1c..c148f8ed7b4 100644 --- a/addons/point_of_sale/static/src/js/chrome.js +++ b/addons/point_of_sale/static/src/js/chrome.js @@ -248,7 +248,7 @@ var DebugWidget = PosBaseWidget.extend({ 'body': _t('This operation will permanently destroy all paid orders from the local storage. You will lose all the data. This operation cannot be undone.'), confirm: function(){ self.pos.db.remove_all_orders(); - self.pos.set({synch: { state:'connected', pending: 0 }}); + self.pos.set_synch('connected', 0); }, }); }); diff --git a/addons/point_of_sale/static/src/js/db.js b/addons/point_of_sale/static/src/js/db.js index 96083b0a247..cb4bd43b83f 100644 --- a/addons/point_of_sale/static/src/js/db.js +++ b/addons/point_of_sale/static/src/js/db.js @@ -488,6 +488,54 @@ var PosDB = core.Class.extend({ } return orders; }, + /** + * Return the orders with requested ids if they are unpaid. + * @param {array<number>} ids order_ids. + * @return {array<object>} list of orders. + */ + get_unpaid_orders_to_sync: function(ids){ + var saved = this.load('unpaid_orders',[]); + var orders = []; + saved.forEach(function(o) { + if (ids.includes(o.id) && (o.data.server_id || o.data.lines.length)){ + orders.push(o); + } + }); + return orders; + }, + /** + * Add a given order to the orders to be removed from the server. + * + * If an order is removed from a table it also has to be removed from the server to prevent it from reapearing + * after syncing. This function will add the server_id of the order to a list of orders still to be removed. + * @param {object} order object. + */ + set_order_to_remove_from_server: function(order){ + if (order.server_id !== undefined) { + var to_remove = this.load('unpaid_orders_to_remove',[]); + to_remove.push(order.server_id); + this.save('unpaid_orders_to_remove', to_remove); + } + }, + /** + * Get a list of server_ids of orders to be removed. + * @return {array<number>} list of server_ids. + */ + get_ids_to_remove_from_server: function(){ + return this.load('unpaid_orders_to_remove',[]); + }, + /** + * Remove server_ids from the list of orders to be removed. + * @param {array<number>} ids + */ + set_ids_removed_from_server: function(ids){ + var to_remove = this.load('unpaid_orders_to_remove',[]); + + to_remove = _.filter(to_remove, function(id){ + return !ids.includes(id); + }); + this.save('unpaid_orders_to_remove', to_remove); + }, set_cashier: function(cashier) { // Always update if the user is the same as before this.save('cashier', cashier || null); diff --git a/addons/point_of_sale/static/src/js/gui.js b/addons/point_of_sale/static/src/js/gui.js index d50ad71faea..0253e4aece6 100644 --- a/addons/point_of_sale/static/src/js/gui.js +++ b/addons/point_of_sale/static/src/js/gui.js @@ -25,6 +25,7 @@ var Gui = core.Class.extend({ this.startup_screen = null; this.current_popup = null; this.current_screen = null; + this.show_sync_errors = true; this.chrome.ready.then(function(){ self.close_other_tabs(); @@ -165,6 +166,15 @@ var Gui = core.Class.extend({ this.current_popup = this.popup_instances[name]; return this.current_popup.show(options); }, + show_sync_error_popup: function() { + if (this.show_sync_errors) { + this.show_popup('error-sync',{ + 'title':_t('Changes could not be saved'), + 'body': _t('You must be connected to the internet to save your changes.\n\n' + + 'Orders that where not synced before will be synced next time you close an order while connected to the internet or when you close the session.'), + }); + } + }, // close the current popup. close_popup: function() { diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 7a3fa82e252..a2da7016a4a 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -67,6 +67,10 @@ exports.PosModel = Backbone.Model.extend({ this.order_sequence = 1; window.posmodel = this; + // 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; + // these dynamic attributes can be watched for change by other models or widgets this.set({ 'synch': { state:'connected', pending:0 }, @@ -251,15 +255,23 @@ exports.PosModel = Backbone.Model.extend({ },{ model: 'pos.session', fields: ['id', 'name', 'user_id', 'config_id', 'start_at', 'stop_at', 'sequence_number', 'login_number', 'payment_method_ids'], - domain: function(self){ return [['state','=','opened'],['user_id','=',session.uid]]; }, - loaded: function(self,pos_sessions,tmp){ + domain: function(self){ + var domain = [ + ['state','=','opened'], + ['rescue', '=', false], + ]; + if (self.config_id) domain.push(['config_id', '=', self.config_id]); + return domain; + }, + loaded: function(self, pos_sessions, tmp){ self.pos_session = pos_sessions[0]; + self.config_id = self.config_id || self.pos_session && self.pos_session.config_id[0]; tmp.payment_method_ids = pos_sessions[0].payment_method_ids; }, },{ model: 'pos.config', fields: [], - domain: function(self){ return [['id','=', self.pos_session.config_id[0]]]; }, + domain: function(self){ return [['id','=', self.config_id]]; }, loaded: function(self,configs){ self.config = configs[0]; self.config.use_proxy = self.config.iface_payment_terminal || @@ -860,6 +872,7 @@ exports.PosModel = Backbone.Model.extend({ transfer.then(function(order_server_id){ // generate the pdf and download it if (order_server_id.length) { + order_server_id[0] = order_server_id[0]["id"]; self.chrome.do_action('point_of_sale.pos_invoice_report',{additional_context:{ active_ids:order_server_id, }}).then(function () { @@ -889,27 +902,24 @@ exports.PosModel = Backbone.Model.extend({ // wrapper around the _save_to_server that updates the synch status widget _flush_orders: function(orders, options) { var self = this; - this.set('synch',{ state: 'connecting', pending: orders.length}); - - return self._save_to_server(orders, options).then(function (server_ids) { - var pending = self.db.get_orders().length; + this.set_synch('connecting', orders.length); - self.set('synch', { - state: pending ? 'connecting' : 'connected', - pending: pending - }); - - return server_ids; + return this._save_to_server(orders, options).then(function (server_ids) { + self.set_synch('connected'); + return _.pluck(server_ids, 'id'); }).catch(function(){ - var pending = self.db.get_orders().length; - if (self.get('failed')) { - self.set('synch', { state: 'error', pending: pending }); - } else { - self.set('synch', { state: 'disconnected', pending: pending }); - } + self.set_synch(self.get('failed') ? 'error' : 'disconnected'); }); }, + set_synch: function(state, pending) { + if (['connected', 'connecting', 'error', 'disconnected'].indexOf(state) === -1) { + console.error(state, ' is not a known connection state.'); + } + pending = pending || this.db.get_orders().length + this.db.get_ids_to_remove_from_server().length; + this.set('synch', { state: state, pending: pending }); + }, + // send an array of orders to the server // available options: // - timeout: timeout for the rpc call in ms @@ -936,6 +946,7 @@ exports.PosModel = Backbone.Model.extend({ order.to_invoice = options.to_invoice || false; return order; })]; + args.push(options.draft || false); return rpc.query({ model: 'pos.order', method: 'create_from_ui', @@ -969,6 +980,49 @@ exports.PosModel = Backbone.Model.extend({ self.set('failed',error); } console.error('Failed to send orders:', orders); + self.gui.show_sync_error_popup(); + throw reason; + }); + }, + + /** + * Remove orders with given ids from the database. + * @param {array<number>} server_ids ids of the orders to be removed. + * @param {dict} options. + * @param {number} options.timeout optional timeout parameter for the rpc call. + * @return {Promise<array<number>>} returns a promise of the ids successfully removed. + */ + _remove_from_server: function (server_ids, options) { + options = options || {}; + if (!server_ids || !server_ids.length) { + return Promise.resolve([]); + } + + var self = this; + var timeout = typeof options.timeout === 'number' ? options.timeout : 7500 * server_ids.length; + + return rpc.query({ + model: 'pos.order', + method: 'remove_from_ui', + args: [server_ids], + kwargs: {context: session.user_context}, + }, { + timeout: timeout, + shadow: true, + }) + .then(function (ids) { + self.db.set_ids_removed_from_server(server_ids); + return server_ids; + }).catch(function (reason){ + var 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; + } + } + self.gui.show_sync_error_popup(); + console.error('Failed to remove orders:', server_ids); }); }, @@ -1334,7 +1388,7 @@ exports.Orderline = Backbone.Model.extend({ this.price = json.price_unit; this.set_discount(json.discount); this.set_quantity(json.qty, 'do not recompute unit price'); - this.id = json.id; + this.id = json.id ? json.id : orderline_id++; orderline_id = Math.max(this.id+1,orderline_id); var pack_lot_lines = json.pack_lot_ids; for (var i = 0; i < pack_lot_lines.length; i++) { @@ -2152,6 +2206,8 @@ exports.Order = Backbone.Model.extend({ this.uid = json.uid; this.name = _t("Order ") + this.uid; this.validation_date = json.creation_date; + this.server_id = json.server_id ? json.server_id : false; + this.user_id = json.user_id; if (json.fiscal_position_id) { var fiscal_position = _.find(this.pos.fiscal_positions, function (fp) { @@ -2213,7 +2269,7 @@ exports.Order = Backbone.Model.extend({ this.paymentlines.each(_.bind( function(item) { return paymentLines.push([0, 0, item.export_as_JSON()]); }, this)); - return { + var json = { name: this.get_name(), amount_paid: this.get_total_paid() - this.get_change(), amount_total: this.get_total_with_tax(), @@ -2229,8 +2285,13 @@ exports.Order = Backbone.Model.extend({ uid: this.uid, sequence_number: this.sequence_number, creation_date: this.validation_date || this.creation_date, // todo: rename creation_date in master - fiscal_position_id: this.fiscal_position ? this.fiscal_position.id : false + fiscal_position_id: this.fiscal_position ? this.fiscal_position.id : false, + server_id: this.server_id ? this.server_id : false, }; + if (!this.is_paid && this.user_id) { + json.user_id = this.user_id; + } + return json; }, export_for_printing: function(){ var orderlines = []; diff --git a/addons/point_of_sale/static/src/js/popups.js b/addons/point_of_sale/static/src/js/popups.js index e27ffc75dd6..89d9b8d6a7e 100644 --- a/addons/point_of_sale/static/src/js/popups.js +++ b/addons/point_of_sale/static/src/js/popups.js @@ -98,6 +98,21 @@ var ErrorPopupWidget = PopupWidget.extend({ gui.define_popup({name:'error', widget: ErrorPopupWidget}); +var SyncErrorPopupWidget = ErrorPopupWidget.extend({ + template:'SyncErrorPopupWidget', + show: function(opts) { + var self = this; + this._super(opts); + + this.$('.stop_showing_sync_errors').off('click').click(function(){ + self.gui.show_sync_errors = false; + self.click_confirm(); + }); + } +}); +gui.define_popup({name:'error-sync', widget: SyncErrorPopupWidget}); + + var ErrorTracebackPopupWidget = ErrorPopupWidget.extend({ template:'ErrorTracebackPopupWidget', show: function(opts) { diff --git a/addons/point_of_sale/static/src/xml/pos.xml b/addons/point_of_sale/static/src/xml/pos.xml index 3d4c2462b94..26fe124b55e 100644 --- a/addons/point_of_sale/static/src/xml/pos.xml +++ b/addons/point_of_sale/static/src/xml/pos.xml @@ -1052,6 +1052,23 @@ </div> </t> + <t t-name="SyncErrorPopupWidget"> + <div role="dialog" class="modal-dialog"> + <div class="popup popup-error"> + <header class="title"><t t-esc=" widget.options.title || 'Error' " /></header> + <main class="body traceback"><t t-esc=" widget.options.body || '' "/></main> + <footer class="footer"> + <div class="button cancel"> + Ok + </div> + <div class="button stop_showing_sync_errors"> + Don't show again + </div> + </footer> + </div> + </div> + </t> + <t t-name="ErrorTracebackPopupWidget"> <div role="dialog" class="modal-dialog"> <div class="popup popup-error"> @@ -1469,15 +1486,14 @@ <t t-if='order === widget.pos.get_order()'> <span class="order-button select-order selected" t-att-data-uid="order.uid"> <span class='order-sequence'> - <t t-esc='order.sequence_number' /> + <t t-esc="order.uid.substring(order.uid.indexOf('-') +1)"/> </span> - <t t-esc="moment(order.creation_date).format('hh:mm')"/> </span> </t> <t t-if='order !== widget.pos.get_order()'> <span class="order-button select-order" t-att-data-uid="order.uid"> <span class='order-sequence'> - <t t-esc='order.sequence_number' /> + <t t-esc="order.uid.substring(order.uid.indexOf('-') +1)"/> </span> </span> </t> diff --git a/addons/point_of_sale/static/tests/tours/point_of_sale.js b/addons/point_of_sale/static/tests/tours/point_of_sale.js index 71cbeef006b..352624a61c3 100644 --- a/addons/point_of_sale/static/tests/tours/point_of_sale.js +++ b/addons/point_of_sale/static/tests/tours/point_of_sale.js @@ -45,10 +45,6 @@ odoo.define('point_of_sale.tour.pricelist', function (require) { } var steps = [{ // Leave category displayed by default - content: "click category switch", - trigger: ".js-category-switch", - run: 'click', - }, { content: 'waiting for loading to finish', trigger: 'body:has(.loader:hidden)', run: function () { @@ -96,6 +92,10 @@ odoo.define('point_of_sale.tour.pricelist', function (require) { trigger: ".pos.done-testing", run: function () {}, // it's a check }, { + content: "click category switch", + trigger: ".js-category-switch", + run: 'click', + }, { content: "click pricelist button", trigger: ".control-button.o_pricelist_button", }, { diff --git a/addons/point_of_sale/tests/test_frontend.py b/addons/point_of_sale/tests/test_frontend.py index 593c88876dc..772741a188d 100644 --- a/addons/point_of_sale/tests/test_frontend.py +++ b/addons/point_of_sale/tests/test_frontend.py @@ -323,9 +323,9 @@ class TestUi(odoo.tests.HttpCase): # this you end up with js, css but no qweb. env['ir.module.module'].search([('name', '=', 'point_of_sale')], limit=1).state = 'installed' - self.start_tour("/pos/web", 'pos_pricelist', login="admin") + self.start_tour("/pos/web?config_id=%d" % main_pos_config.id, 'pos_pricelist', login="admin") - self.start_tour("/pos/web", 'pos_basic_order', login="admin") + self.start_tour("/pos/web?config_id=%d" % main_pos_config.id, 'pos_basic_order', login="admin") for order in env['pos.order'].search([]): self.assertEqual(order.state, 'paid', "Validated order has payment of " + str(order.amount_paid) + " and total of " + str(order.amount_total)) diff --git a/addons/point_of_sale/tests/test_point_of_sale_flow.py b/addons/point_of_sale/tests/test_point_of_sale_flow.py index 3466b4ca112..b42b8129cfe 100644 --- a/addons/point_of_sale/tests/test_point_of_sale_flow.py +++ b/addons/point_of_sale/tests/test_point_of_sale_flow.py @@ -540,10 +540,6 @@ class TestPointOfSaleFlow(TestPointOfSaleCommon): self.PosOrder.create_from_ui([carrot_order]) self.assertEqual(num_starting_orders + 1, len(current_session.order_ids), "Submitted order not encoded") - # I resubmit the same order - self.PosOrder.create_from_ui([carrot_order]) - self.assertEqual(num_starting_orders + 1, len(current_session.order_ids), "Resubmitted order was not skipped") - # I close the session current_session.action_pos_session_closing_control() self.assertEqual(current_session.state, 'closed', "Session was not properly closed") diff --git a/addons/point_of_sale/views/point_of_sale_dashboard.xml b/addons/point_of_sale/views/point_of_sale_dashboard.xml index 00327b9daba..7a5fe9ab3dc 100644 --- a/addons/point_of_sale/views/point_of_sale_dashboard.xml +++ b/addons/point_of_sale/views/point_of_sale_dashboard.xml @@ -63,6 +63,9 @@ <t t-if="record.pos_session_state.raw_value == 'new_session'"> <div class="badge badge-info o_kanban_inline_block">New session</div> </t> + <t t-if="!record.current_session_id.raw_value && record.pos_session_username.value"> + <div class="badge badge-info o_kanban_inline_block">Opened by <t t-esc="record.pos_session_username.value"/></div> + </t> <t t-if="record.pos_session_state.raw_value == 'opening_control'"> <div class="badge badge-info o_kanban_inline_block">Opening Control</div> </t> diff --git a/addons/pos_hr/static/src/js/screens.js b/addons/pos_hr/static/src/js/screens.js index 425bec4d78b..402bcbd7496 100644 --- a/addons/pos_hr/static/src/js/screens.js +++ b/addons/pos_hr/static/src/js/screens.js @@ -92,7 +92,7 @@ var LoginScreenWidget = ScreenWidget.extend({ }, unlock_screen: function() { - var screen = (this.gui.pos.get_order() ? this.gui.pos.get_order().get_screen_data('previous-screen') : this.gui.default_screen) || this.gui.default_screen; + var screen = (this.gui.pos.get_order() ? this.gui.pos.get_order().get_screen_data('previous-screen') : this.gui.startup_screen) || this.gui.startup_screen; this.gui.show_screen(screen); } }); diff --git a/addons/pos_restaurant/models/pos_config.py b/addons/pos_restaurant/models/pos_config.py index 07c2dd88253..0dfe375ed9b 100644 --- a/addons/pos_restaurant/models/pos_config.py +++ b/addons/pos_restaurant/models/pos_config.py @@ -42,3 +42,11 @@ class PosConfig(models.Model): def _onchange_is_order_printer(self): if not self.is_order_printer: self.printer_ids = [(5, 0, 0)] + + def get_tables_order_count(self): + """ """ + self.ensure_one() + result = [] + for table in self.floor_ids.table_ids.filtered(lambda t: t.active == True): + result.append({'id': table.id, 'orders': self.env['pos.order'].search_count([('state', '=', 'draft'), ('table_id', '=', table.id)])}) + return result diff --git a/addons/pos_restaurant/models/pos_order.py b/addons/pos_restaurant/models/pos_order.py index fa6ba65eec5..50220379b1a 100644 --- a/addons/pos_restaurant/models/pos_order.py +++ b/addons/pos_restaurant/models/pos_order.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from itertools import groupby from odoo import api, fields, models @@ -17,6 +18,153 @@ class PosOrder(models.Model): table_id = fields.Many2one('restaurant.table', string='Table', help='The table where this order was served') customer_count = fields.Integer(string='Guests', help='The amount of customers that have been served by this 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_lots) + + 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 = [ + 'id', + 'discount', + 'product_id', + 'price_unit', + 'order_id', + 'qty', + 'note', + 'mp_skip', + ]) + + if order_lines != []: + self._get_pack_lot_lines(order_lines) + + extended_order_lines = [] + for order_line in order_lines: + 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'] = [] + extended_order_lines.append([0, 0, 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_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. + """ + payment_lines = self.env['pos.payment'].search_read( + domain = [('pos_order_id', 'in', [po['id'] for po in orders])], + fields = [ + 'id', + 'amount', + 'pos_order_id', + 'payment_method_id', + ]) + + 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]) + 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) + + @api.model + def get_table_draft_orders(self, table_id): + """Generate an object of all draft orders for the given table. + + Generate and return an JSON object with all draft orders for the given table, to send to the + front end application. + + :param table_id: Id of the selected table. + :type table_id: int. + :returns: list -- list of dict representing the table orders + """ + table_orders = self.search_read( + domain = [('state', '=', 'draft'), ('table_id', '=', table_id)], + fields = [ + 'id', + 'pricelist_id', + 'partner_id', + 'sequence_number', + 'session_id', + 'pos_reference', + 'create_uid', + 'create_date', + 'customer_count', + 'fiscal_position_id', + 'table_id', + 'to_invoice', + ]) + + self._get_order_lines(table_orders) + self._get_payment_lines(table_orders) + + for order in table_orders: + order['pos_session_id'] = order['session_id'] + order['uid'] = order['pos_reference'].split(' ')[1] + 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'] + + return table_orders + @api.model def _order_fields(self, ui_order): order_fields = super(PosOrder, self)._order_fields(ui_order) diff --git a/addons/pos_restaurant/static/src/js/floors.js b/addons/pos_restaurant/static/src/js/floors.js index ba75ef52366..847c9b2f22d 100644 --- a/addons/pos_restaurant/static/src/js/floors.js +++ b/addons/pos_restaurant/static/src/js/floors.js @@ -8,6 +8,7 @@ var models = require('point_of_sale.models'); var screens = require('point_of_sale.screens'); var core = require('web.core'); var rpc = require('web.rpc'); +var session = require('web.session'); var QWeb = core.qweb; var _t = core._t; @@ -218,10 +219,7 @@ var TableWidget = PosBaseWidget.extend({ self.renderElement(); }); }, function(type,err) { - self.gui.show_popup('error',{ - 'title':_t('Changes could not be saved'), - 'body': _t('You must be connected to the internet to save your changes.'), - }); + self.gui.show_sync_error_popup(); }); }, // destroy the table. We do not really destroy it, we set it @@ -257,10 +255,7 @@ var TableWidget = PosBaseWidget.extend({ floorplan.update_toolbar(); self.destroy(); }, function(type, err) { - self.gui.show_popup('error', { - 'title':_t('Changes could not be saved'), - 'body': _t('You must be connected to the internet to save your changes.'), - }); + self.gui.show_sync_error_popup(); }); }, get_notifications: function(){ //FIXME : Make this faster @@ -276,19 +271,22 @@ var TableWidget = PosBaseWidget.extend({ } return notifications; }, - update_click_handlers: function(editing){ - var self = this; - this.$el.off('mouseup touchend touchcancel click dragend'); + update_click_handlers: function(editing){ + var self = this; + this.$el.off('mouseup touchend touchcancel click dragend'); - if (editing) { - this.$el.on('mouseup touchend touchcancel', function(event){ self.click_handler(event,$(this)); }); - } else { - this.$el.on('click dragend', function(event){ self.click_handler(event,$(this)); }); - } - }, + if (editing) { + this.$el.on('mouseup touchend touchcancel', function(event){ self.click_handler(event,$(this)); }); + } else { + this.$el.on('click dragend', function(event){ self.click_handler(event,$(this)); }); + } + }, renderElement: function(){ var self = this; - this.order_count = this.pos.get_table_orders(this.table).length; + this.order_count = this.table.order_count !== undefined ? + this.table.order_count : + this.pos.get_table_orders(this.table) + .filter(o => o.orderlines.length !== 0 || o.paymentlines.length !==0).length; this.customer_count = this.pos.get_customer_count(this.table); this.fill = Math.min(1,Math.max(0,this.customer_count / this.table.seats)); this.notifications = this.get_notifications(); @@ -315,12 +313,41 @@ var FloorScreenWidget = screens.ScreenWidget.extend({ this.selected_table = null; this.editing = false; }, + _table_longpolling: function(){ + var self = this; + rpc.query({ + model: 'pos.config', + method: 'get_tables_order_count', + args: [this.pos.config.id], + }) + .then(function (result){ + result.forEach(function(table){ + var table_obj = self.pos.tables_by_id[table.id]; + var unsynced_orders = self.pos.get_table_orders(table_obj) + .filter(o => o.server_id === undefined && + (o.orderlines.length !== 0 || o.paymentlines.length !== 0)).length + table_obj.order_count = table.orders + unsynced_orders; + }); + self.table_widgets.forEach( + function(tw){ + tw.renderElement(); + }); + }, function(type,err) { + self.table_widgets.forEach( + function(tw){ + tw.table.order_count = self.pos.get_table_orders(tw.table) + .filter(o => o.orderlines.length !== 0 || o.paymentlines.length !== 0).length + tw.renderElement(); + }); + }); + }, hide: function(){ this._super(); if (this.editing) { this.toggle_editing(); } this.chrome.widget.order_selector.show(); + clearInterval(this.table_longpolling); }, show: function(){ this._super(); @@ -329,6 +356,9 @@ var FloorScreenWidget = screens.ScreenWidget.extend({ this.table_widgets[i].renderElement(); } this.check_empty_floor(); + + this._table_longpolling(); + this.table_longpolling = setInterval(this._table_longpolling.bind(this), 5000); }, click_floor_button: function(event,$el){ var floor = this.pos.floors_by_id[$el.data('id')]; @@ -364,10 +394,7 @@ var FloorScreenWidget = screens.ScreenWidget.extend({ args: [[this.floor.id], {'background_color': background}], }) .guardedCatch(function (){ - self.gui.show_popup('error',{ - 'title':_t('Changes could not be saved'), - 'body': _t('You must be connected to the internet to save your changes.'), - }); + self.gui.show_sync_error_popup(); }); this.$('.floor-map').css({"background-color": _.escape(background)}); }, @@ -662,6 +689,19 @@ gui.define_screen({ }, }); +gui.Gui.include({ + show_sync_error_popup: function() { + if (this.show_sync_errors) { + this.show_popup('error-sync',{ + 'title':_t('Changes could not be saved'), + 'body': _t('You must be connected to the internet to save your changes.\n\n' + + 'Changes made to previously synced orders will get lost at the next sync.\n' + + 'Orders that where not synced before will be synced next time you open and close the same table.'), + }); + } + }, +}); + // Add the FloorScreen to the GUI, and set it as the default screen chrome.Chrome.include({ build_widgets: function(){ @@ -753,9 +793,18 @@ chrome.OrderSelectorWidget.include({ // if there is none. var _super_posmodel = models.PosModel.prototype; models.PosModel = models.PosModel.extend({ - initialize: function(session, attributes) { - this.table = null; - return _super_posmodel.initialize.call(this,session,attributes); + after_load_server_data: function() { + _super_posmodel.after_load_server_data.call(this); + if (this.config.iface_floorplan) { + var self = this; + this.table = null; + $('.screen').not('.floor-screen').onmousemove = function() {self.set_idle_timer()}; + $('.screen').not('.floor-screen').onmousedown = function() {self.set_idle_timer()}; // touchscreen presses + $('.screen').not('.floor-screen').ontouchstart = function() {self.set_idle_timer()}; + $('.screen').not('.floor-screen').onclick = function() {self.set_idle_timer()}; // touchpad clicks + $('.screen').not('.floor-screen').onscroll = function() {self.set_idle_timer()}; // scrolling with arrow keys + $('.screen').not('.floor-screen').onkeypress = function() {self.set_idle_timer()}; + } }, transfer_order_to_different_table: function () { @@ -767,26 +816,154 @@ models.PosModel = models.PosModel.extend({ this.set_table(null); }, - // changes the current table. + remove_from_server_and_set_sync_state: function(ids_to_remove){ + var self = this; + this.set_synch('connecting', ids_to_remove.length); + self._remove_from_server(ids_to_remove) + .then(function(server_ids) { + self.set_synch('connected'); + }).catch(function(reason){ + self.set_synch('error'); + }); + }, + + /** + * Request the orders of the table with given id. + * @param {number} table_id. + * @param {dict} options. + * @param {number} options.timeout optional timeout parameter for the rpc call. + * @return {Promise} + */ + _get_from_server: function (table_id, options) { + options = options || {}; + var self = this; + var timeout = typeof options.timeout === 'number' ? options.timeout : 7500; + return rpc.query({ + model: 'pos.order', + method: 'get_table_draft_orders', + args: [table_id], + kwargs: {context: session.user_context}, + }, { + timeout: timeout, + shadow: false, + }) + }, + + /** + * Set or unset a timeout to go back to the floorplan. + * + * if deactivate is true unset the timeout, Else set a timeout to go back to the floorplan and + * force a sync of the current table. + * @param {bool} deactivate optional boolean, default false. + * @param {number} timeout, optional timeout in miliseconds, default one minute. + */ + set_idle_timer: function(deactivate, timeout) { + timeout = timeout || 60000; + deactivate = deactivate || false; + if (this.idle_timer) { + clearTimeout(this.idle_timer); + } + var self = this; + if (deactivate) { + clearTimeout(this.idle_timer); + } else { + this.idle_timer = setTimeout(function(){self.set_table(null)}, timeout); + } + }, + + /** + * Changes the current table. + * + * Switch table and make sure all nececery syncing tasks are done. + * @param {object} table. + */ set_table: function(table) { - if (!table) { // no table ? go back to the floor plan, see ScreenSelector - this.set_order(null); - } else if (this.order_to_transfer_to_different_table) { - this.order_to_transfer_to_different_table.table = table; - this.order_to_transfer_to_different_table.save_to_db(); - this.order_to_transfer_to_different_table = null; + var self = this; + var ids_to_remove = this.db.get_ids_to_remove_from_server(); + if (!table || this.order_to_transfer_to_different_table) { // no table ? go back to the floor plan, see ScreenSelector + this.set_idle_timer(true); + var order_ids = []; + var table_orders = this.get_order_list(); + table_orders.forEach(function(o){ + order_ids.push(o.uid); + }); - // set this table - this.set_table(table); + if (this.order_to_transfer_to_different_table && table) { + this.order_to_transfer_to_different_table.table = table; + this.order_to_transfer_to_different_table.save_to_db(); + order_ids.push(this.order_to_transfer_to_different_table.uid); + table_orders.push(this.order_to_transfer_to_different_table); + } + + var orders_to_sync = this.db.get_unpaid_orders_to_sync(order_ids); + if (orders_to_sync.length) { + this.set_synch('connecting', orders_to_sync.length); + this._save_to_server(orders_to_sync, {'draft': true}) + .then(function (server_ids) { + server_ids.forEach(function(server_id){ + table_orders.some(function(o){ + if (o.name === server_id.pos_reference) { + o.server_id = server_id.id; + o.save_to_db(); + } + }); + }); + if (!ids_to_remove.length) { + self.set_synch('connected'); + } else { + self.remove_from_server_and_set_sync_state(ids_to_remove); + } + }).catch(function(reason){ + self.set_synch('error'); + }).finally(function(){ + if (self.order_to_transfer_to_different_table && table) { + self.order_to_transfer_to_different_table = null; + self.set_table(table); + } + }); + } else if (ids_to_remove.length) { + self.remove_from_server_and_set_sync_state(ids_to_remove); + } + this.set_order(null); // unset curent selected order } else { + + clearInterval(this.table_longpolling); this.table = table; - var orders = this.get_order_list(); - if (orders.length) { - this.set_order(orders[0]); // and go to the first one ... - } else { - this.add_new_order(); // or create a new order with the current table - } + this.set_idle_timer(); + + this.set_synch('connecting', 1); + this._get_from_server(table.id) + .then(function (server_orders) { + var orders = self.get_order_list(); + orders.forEach(function(order){ + if (order.server_id){ + self.get("orders").remove(order); + order.destroy(); + } + }); + server_orders.forEach(function(server_order){ + if (server_order.lines.length){ + var new_order = new models.Order({},{pos: self, json: server_order}); + self.get("orders").add(new_order); + new_order.save_to_db(); + } + }) + if (!ids_to_remove.length) { + self.set_synch('connected'); + } else { + self.remove_from_server_and_set_sync_state(ids_to_remove); + } + }).catch(function(reason){ + self.set_synch('error'); + }).finally(function(){ + var orders = self.get_order_list(); + if (orders.length) { + self.set_order(orders[0]); // and go to the first one ... + } else { + self.add_new_order(); // or create a new order with the current table + } + }); } }, @@ -860,6 +1037,9 @@ models.PosModel = models.PosModel.extend({ on_removed_order: function(removed_order,index,reason){ if (this.config.iface_floorplan) { var order_list = this.get_order_list(); + if (reason === 'abandon') { + this.db.set_order_to_remove_from_server(removed_order); + } if( (reason === 'abandon' || removed_order.temporary) && order_list.length > 0){ this.set_order(order_list[index] || order_list[order_list.length -1]); }else{ diff --git a/addons/pos_restaurant/static/src/js/tours/pos_restaurant.js b/addons/pos_restaurant/static/src/js/tours/pos_restaurant.js new file mode 100644 index 00000000000..b0f8a23f05b --- /dev/null +++ b/addons/pos_restaurant/static/src/js/tours/pos_restaurant.js @@ -0,0 +1,228 @@ +odoo.define('pos_reataurant.tour.synchronized_table_management', function (require) { + "use strict"; + + var Tour = require("web_tour.tour"); + + function verify_order_total(total_str) { + return [{ + content: 'order total contains ' + total_str, + trigger: '.order .total .value:contains("' + total_str + '")', + run: function () {}, // it's a check + }]; + } + + function verify_sync() { + return [{ + content: "verify that the order is being sent to the backend", + trigger: ".js_connecting:visible", + run: function () {}, // it's a check + }, { + content: "verify that the order has been succesfully sent to the backend", + trigger: ".js_connected:visible", + run: function () {}, // it's a check + }]; + } + + function verify_orders_synced(order_count) { + return [{ + content: "check synced", + trigger: ".order-sequence", + run: function() { + var orders = $('.order-sequence'); + if (orders.length === order_count) { + var fail = orders.toArray().some(function(order){ + return !order.firstChild.data.includes('S-'); + }); + if (fail) { + throw "sync failed"; + } + } else { + throw "sync failed"; + } + }, + }]; + } + + function add_product_to_order(product_name) { + return [{ + content: 'buy ' + product_name, + trigger: '.product-list .product-name:contains("' + product_name + '")', + }, { + content: 'the ' + product_name + ' have been added to the order', + trigger: '.order .product-name:contains("' + product_name + '")', + run: function () {}, // it's a check + }]; + } + + function generate_keypad_steps(amount_str, keypad_selector) { + var i, steps = [], current_char; + for (i = 0; i < amount_str.length; ++i) { + current_char = amount_str[i]; + steps.push({ + content: 'press ' + current_char + ' on payment keypad', + trigger: keypad_selector + ' .input-button:contains("' + current_char + '"):visible' + }); + } + + return steps; + } + + function generate_payment_screen_keypad_steps(amount_str) { + return generate_keypad_steps(amount_str, '.payment-numpad'); + } + + function generate_product_screen_keypad_steps(amount_str) { + return generate_keypad_steps(amount_str, '.numpad'); + } + + function goto_payment_screen_and_select_payment_method() { + return [{ + content: "go to payment screen", + trigger: '.button.pay', + }, { + content: "pay with cash", + trigger: '.paymentmethod:contains("Cash")', + }]; + } + + function open_table(table_id, order_count) { + order_count = order_count || null; + var steps = [{ + content: 'open table ' + table_id, + trigger: '.label:contains(' + table_id +')', + run: 'click', + }]; + steps = steps.concat(verify_sync()); + if (order_count !== null){ + steps = steps.concat(verify_orders_synced(order_count)); + } + return steps; + } + + function finish_order() { + var steps = [{ + content: "validate the order", + trigger: '.button.next:visible', + }]; + steps = steps.concat(verify_sync()); + steps = steps.concat([{ + content: "next order", + trigger: '.button.next:visible', + }]); + return steps; + } + + /* pos_restaurant_sync + * + * Run on new session. + */ + var steps = [{ + content: 'waiting for loading to finish', + trigger: 'body:has(.loader:hidden)', + run: function () {}, + }] + + + steps = steps.concat(open_table('T5')); + + steps = steps.concat(add_product_to_order('Coca-Cola')); + steps = steps.concat(add_product_to_order('Water')); + steps = steps.concat(verify_order_total('4.40')); + steps = steps.concat([{ + content: 'start new order', + trigger: '.neworder-button', + run: 'click', + }]); + steps = steps.concat(add_product_to_order('Coca-Cola')); + steps = steps.concat(add_product_to_order('Minute Maid')); + steps = steps.concat(verify_order_total('4.40')); + steps = steps.concat(goto_payment_screen_and_select_payment_method()); + steps = steps.concat(generate_payment_screen_keypad_steps('6.05')); + steps = steps.concat(finish_order()); + steps = steps.concat(verify_sync()); + steps = steps.concat(open_table('T5', 1)); + steps = steps.concat(verify_order_total('4.40')); + steps = steps.concat([{ + content: 'start new order', + trigger: '.neworder-button', + run: 'click', + }]); + steps = steps.concat(add_product_to_order('Coca-Cola')); + steps = steps.concat(add_product_to_order('Minute Maid')); + steps = steps.concat([{ + content: 'back to floor', + trigger: '.floor-button', + run: 'click', + }]); + steps = steps.concat(verify_sync()); + steps = steps.concat(open_table('T5', 2)); + steps = steps.concat([{ + content: 'delete order', + trigger: '.deleteorder-button', + run: 'click', + }, { + content: 'confirm delete', + trigger: '.button.confirm', + run: 'click', + }, { + content: 'back to floor', + trigger: '.floor-button', + run: 'click', + }]); + steps = steps.concat(verify_sync()); + steps = steps.concat(open_table('T5', 1)); + + steps = steps.concat([{ + content: "close the Point of Sale frontend", + trigger: ".header-button", + }, { + content: "confirm closing the frontend", + trigger: ".header-button", + run: function() {}, //it's a check, + }, { + content: "close the Point of Sale frontend", + trigger: ".header-button", + }]); + + Tour.register('pos_restaurant_sync', { test: true, url: '/pos/web' }, steps); + + + /* pos_restaurant_sync_second_login + * + * This tour should be run after the first tour is done. + */ + var steps = [{ + content: 'waiting for loading to finish', + trigger: 'body:has(.loader:hidden)', + run: function () {}, + }]; + steps = steps.concat(open_table('T5', 1)); + steps = steps.concat(verify_order_total('4.40')); + + steps = steps.concat(goto_payment_screen_and_select_payment_method()); + steps = steps.concat(generate_payment_screen_keypad_steps('4.40')); + steps = steps.concat(finish_order()); + steps = steps.concat(open_table('T2')); + steps = steps.concat(add_product_to_order('Coca-Cola')); + steps = steps.concat(verify_order_total('2.20')); + steps = steps.concat([{ + content: 'back to floor', + trigger: '.floor-button', + run: 'click', + }]); + steps = steps.concat(verify_sync()); + steps = steps.concat([{ + content: "close the Point of Sale frontend", + trigger: ".header-button", + }, { + content: "confirm closing the frontend", + trigger: ".header-button", + run: function() {}, //it's a check, + }, { + content: "close the Point of Sale frontend", + trigger: ".header-button", + }]); + + Tour.register('pos_restaurant_sync_second_login', { test: true, url: '/pos/web' }, steps); + +}); diff --git a/addons/pos_restaurant/tests/__init__.py b/addons/pos_restaurant/tests/__init__.py new file mode 100644 index 00000000000..6f4bae0c7a2 --- /dev/null +++ b/addons/pos_restaurant/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_frontend diff --git a/addons/pos_restaurant/tests/test_frontend.py b/addons/pos_restaurant/tests/test_frontend.py new file mode 100644 index 00000000000..9ad26b2b440 --- /dev/null +++ b/addons/pos_restaurant/tests/test_frontend.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import odoo.tests + + +class TestFrontend(odoo.tests.HttpCase): + + def test_01_pos_restaurant(self): + env = self.env(user=self.env.ref('base.user_admin')) + account_obj = env['account.account'] + + pos_config = env.ref('pos_restaurant.pos_config_restaurant') + main_company = env.ref('base.main_company') + + account_receivable = account_obj.create({'code': 'X1012', + 'name': 'Account Receivable - Test', + 'user_type_id': env.ref('account.data_account_type_receivable').id, + 'reconcile': True}) + field = env['ir.model.fields']._get('res.partner', 'property_account_receivable_id') + env['ir.property'].create({'name': 'property_account_receivable_id', + 'company_id': main_company.id, + 'fields_id': field.id, + 'value': 'account.account,' + str(account_receivable.id)}) + test_sale_journal = env['account.journal'].create({ + 'name': 'Sales Journal - Test', + 'code': 'TSJ', + 'type': 'sale', + 'company_id': main_company.id + }) + + cash_journal = env['account.journal'].create({ + 'name': 'Cash Test', + 'code': 'TCJ', + 'type': 'sale', + 'company_id': main_company.id + }) + + pos_config.write({ + 'journal_id': test_sale_journal.id, + 'invoice_journal_id': test_sale_journal.id, + 'payment_method_ids': [(0, 0, { + 'name': 'Cash restaurant', + 'split_transactions': True, + 'receivable_account_id': account_receivable.id, + 'is_cash_count': True, + 'cash_journal_id': cash_journal.id, + })], + }) + + coke = self.env.ref('pos_restaurant.coke') + coke.write({'taxes_id': [(6, 0, [])]}) + water = self.env.ref('pos_restaurant.water') + water.write({'taxes_id': [(6, 0, [])]}) + minute_maid = self.env.ref('pos_restaurant.minute_maid') + minute_maid.write({'taxes_id': [(6, 0, [])]}) + + pos_config.open_session_cb() + + # needed because tests are run before the module is marked as + # installed. In js web will only load qweb coming from modules + # that are returned by the backend in module_boot. Without + # this you end up with js, css but no qweb. + env['ir.module.module'].search([('name', '=', 'pos_restaurant')], limit=1).state = 'installed' + + + self.start_tour("/pos/web?config_id=%d" % pos_config.id, 'pos_restaurant_sync', login="admin") + + self.assertEqual(1, env['pos.order'].search_count([('amount_total', '=', 4.4), ('state', '=', 'draft')])) + self.assertEqual(1, env['pos.order'].search_count([('amount_total', '=', 4.4), ('state', '=', 'paid')])) + + self.start_tour("/pos/web?config_id=%d" % pos_config.id, 'pos_restaurant_sync_second_login', login="admin") + + self.assertEqual(0, env['pos.order'].search_count([('amount_total', '=', 4.4), ('state', '=', 'draft')])) + self.assertEqual(1, env['pos.order'].search_count([('amount_total', '=', 2.2), ('state', '=', 'draft')])) + self.assertEqual(2, env['pos.order'].search_count([('amount_total', '=', 4.4), ('state', '=', 'paid')])) diff --git a/addons/pos_restaurant/views/pos_restaurant_templates.xml b/addons/pos_restaurant/views/pos_restaurant_templates.xml index 61db4c44e24..3143cff8d02 100644 --- a/addons/pos_restaurant/views/pos_restaurant_templates.xml +++ b/addons/pos_restaurant/views/pos_restaurant_templates.xml @@ -15,4 +15,11 @@ </xpath> </template> + <template id="assets_backend" name="hr assets" inherit_id="web.assets_backend"> + <xpath expr="." position="inside"> + <link rel="stylesheet" type="text/scss" href="/point_of_sale/static/src/scss/pos_dashboard.scss"/> + <script type="text/javascript" src="/pos_restaurant/static/src/js/tours/pos_restaurant.js"></script> + </xpath> + </template> + </odoo> |
