summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--addons/point_of_sale/controllers/main.py32
-rw-r--r--addons/point_of_sale/models/pos_config.py18
-rw-r--r--addons/point_of_sale/models/pos_order.py148
-rw-r--r--addons/point_of_sale/models/pos_session.py22
-rw-r--r--addons/point_of_sale/static/src/js/chrome.js2
-rw-r--r--addons/point_of_sale/static/src/js/db.js48
-rw-r--r--addons/point_of_sale/static/src/js/gui.js10
-rw-r--r--addons/point_of_sale/static/src/js/models.js105
-rw-r--r--addons/point_of_sale/static/src/js/popups.js15
-rw-r--r--addons/point_of_sale/static/src/xml/pos.xml22
-rw-r--r--addons/point_of_sale/static/tests/tours/point_of_sale.js8
-rw-r--r--addons/point_of_sale/tests/test_frontend.py4
-rw-r--r--addons/point_of_sale/tests/test_point_of_sale_flow.py4
-rw-r--r--addons/point_of_sale/views/point_of_sale_dashboard.xml3
-rw-r--r--addons/pos_hr/static/src/js/screens.js2
-rw-r--r--addons/pos_restaurant/models/pos_config.py8
-rw-r--r--addons/pos_restaurant/models/pos_order.py148
-rw-r--r--addons/pos_restaurant/static/src/js/floors.js260
-rw-r--r--addons/pos_restaurant/static/src/js/tours/pos_restaurant.js228
-rw-r--r--addons/pos_restaurant/tests/__init__.py4
-rw-r--r--addons/pos_restaurant/tests/test_frontend.py76
-rw-r--r--addons/pos_restaurant/views/pos_restaurant_templates.xml7
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 &amp;&amp; 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>