[IMP] point_of_sale, pos_restaurant: multi-user sessions
PURPOSE ========= When working in restaurants, there are often more than one waiter working on the same floor. But Odoo doesn't support that. There can only be one person working on one session for the same tables. The purpose of this task to allow several people to work on the same session (while synchronizing sessions). SPECIFICATIONS =============== - MultiUser is active by default. - Allow multiple waiters to login on one PoS Config. - All waiters login to the same session, the waiter that does the paiment is the responsible for the order (same as now). - Synchonize the information across the multiple instances running on same floors. Syncronisation will take place at switch of table. (close tables automaticly if no activity, to force sync) - Syncro cannot be done if no internet : how do we manage it ? We should inform the user the internet connection has been lost and that synchro cannot be done. What impacts? To check Useful links https://github.com/it-projects-llc/pos-addons https://apps.odoo.com/apps/modules/11.0/pos_bus_restaurant/ Related PR: odoo#32789 TASK-ID: 1891130 closes odoo/odoo#33973 Signed-off-by: pimodoo <pimodoo@users.noreply.github.com>
This commit is contained in:
committed by
Pierre Masereel
parent
761f695e0d
commit
118190f3b3
@@ -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
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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});
|
||||
this.set_synch('connecting', orders.length);
|
||||
|
||||
return self._save_to_server(orders, options).then(function (server_ids) {
|
||||
var pending = self.db.get_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 = [];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () {
|
||||
@@ -95,6 +91,10 @@ odoo.define('point_of_sale.tour.pricelist', function (require) {
|
||||
content: "wait for unit tests to finish",
|
||||
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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
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;
|
||||
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');
|
||||
});
|
||||
},
|
||||
|
||||
// set this table
|
||||
this.set_table(table);
|
||||
/**
|
||||
* 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) {
|
||||
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);
|
||||
});
|
||||
|
||||
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{
|
||||
|
||||
@@ -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);
|
||||
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import test_frontend
|
||||
@@ -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')]))
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user