diff --git a/addons/point_of_sale/models/pos_config.py b/addons/point_of_sale/models/pos_config.py index 2ebf1f0b79d..1bd4da7e3dd 100644 --- a/addons/point_of_sale/models/pos_config.py +++ b/addons/point_of_sale/models/pos_config.py @@ -127,7 +127,10 @@ class PosConfig(models.Model): pos_session_state = fields.Char(compute='_compute_current_session_user') group_by = fields.Boolean(string='Group Journal Items', default=True, help="Check this if you want to group the Journal Items by Product while closing a Session.") - pricelist_id = fields.Many2one('product.pricelist', string='Pricelist', required=True, default=_default_pricelist) + pricelist_id = fields.Many2one('product.pricelist', string='Default Pricelist', required=True, default=_default_pricelist, + help="The pricelist used if no customer is selected or if the customer has no Sale Pricelist configured.") + available_pricelist_ids = fields.Many2many('product.pricelist', string='Available Pricelists', default=_default_pricelist, + help="Make several pricelists available in the Point of Sale. You can also apply a pricelist to specific customers from their contact form (in Sales tab). To be valid, this pricelist must be listed here as an available pricelist. Otherwise the default pricelist will apply.") company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.user.company_id) barcode_nomenclature_id = fields.Many2one('barcode.nomenclature', string='Barcode Nomenclature', help='Defines what kind of barcodes are available and how they are assigned to products, customers and cashiers.') @@ -143,6 +146,12 @@ class PosConfig(models.Model): default_cashbox_lines_ids = fields.One2many('account.cashbox.line', 'default_pos_id', string='Default Balance') customer_facing_display_html = fields.Html(string='Customer facing display content', translate=True, default=_compute_default_customer_html) use_pricelist = fields.Boolean("Use a pricelist.") + group_sale_pricelist = fields.Boolean("Use pricelists to adapt your price per customers", + implied_group='product.group_sale_pricelist', + help="""Allows to manage different prices based on rules per category of customers. + Example: 10% for retailers, promotion of 5 EUR on this product, etc.""") + group_pricelist_item = fields.Boolean("Show pricelists to customers", + implied_group='product.group_pricelist_item') tax_regime = fields.Boolean("Tax Regime") tax_regime_selection = fields.Boolean("Tax Regime Selection value") barcode_scanner = fields.Boolean("Barcode Scanner") @@ -220,6 +229,19 @@ class PosConfig(models.Model): if self.env['account.journal'].search_count([('id', 'in', self.journal_ids.ids), ('company_id', '!=', self.company_id.id)]): raise ValidationError(_("The company of a payment method is different than the one of point of sale")) + @api.constrains('pricelist_id', 'available_pricelist_ids', 'journal_id', 'invoice_journal_id', 'journal_ids') + def _check_currencies(self): + if self.pricelist_id not in self.available_pricelist_ids: + raise ValidationError(_("The default pricelist must be included in the available pricelists.")) + if any(self.available_pricelist_ids.mapped(lambda pricelist: pricelist.currency_id != self.currency_id)): + raise ValidationError(_("All available pricelists must be in the same currency as the company or" + " as the Sales Journal set on this point of sale if you use" + " the Accounting application.")) + if self.invoice_journal_id.currency_id and self.invoice_journal_id.currency_id != self.currency_id: + raise ValidationError(_("The invoice journal must be in the same currency as the Sales Journal or the company currency if that is not set.")) + if any(self.journal_ids.mapped(lambda journal: journal.currency_id and journal.currency_id != self.currency_id)): + raise ValidationError(_("All payment methods must be in the same currency as the Sales Journal or the company currency if that is not set.")) + @api.onchange('iface_print_via_proxy') def _onchange_iface_print_via_proxy(self): self.iface_print_auto = self.iface_print_via_proxy @@ -237,6 +259,16 @@ class PosConfig(models.Model): """ if not self.use_pricelist: self.pricelist_id = self._default_pricelist() + else: + self.update({ + 'group_sale_pricelist': True, + 'group_pricelist_item': True, + }) + + @api.onchange('available_pricelist_ids') + def _onchange_available_pricelist_ids(self): + if self.pricelist_id not in self.available_pricelist_ids: + self.pricelist_id = False @api.onchange('iface_scan_via_proxy') def _onchange_iface_scan_via_proxy(self): @@ -310,6 +342,7 @@ class PosConfig(models.Model): values['sequence_line_id'] = IrSequence.create(val).id pos_config = super(PosConfig, self).create(values) pos_config.sudo()._check_modules_to_install() + pos_config.sudo()._check_groups_implied() # If you plan to add something after this, use a new environment. The one above is no longer valid after the modules install. return pos_config @@ -318,6 +351,7 @@ class PosConfig(models.Model): result = super(PosConfig, self).write(vals) self.sudo()._set_fiscal_position() self.sudo()._check_modules_to_install() + self.sudo()._check_groups_implied() return result @api.multi @@ -346,6 +380,16 @@ class PosConfig(models.Model): # just in case we want to do something if we install a module. (like a refresh ...) return module_installed + def _check_groups_implied(self): + for pos_config in self: + for field_name in [f for f in pos_config.fields_get_keys() if f.startswith('group_')]: + field = pos_config._fields[field_name] + if field.type in ('boolean', 'selection') and hasattr(field, 'implied_group'): + field_group_xmlids = getattr(field, 'group', 'base.group_user').split(',') + field_groups = self.env['res.groups'].concat(*(self.env.ref(it) for it in field_group_xmlids)) + field_groups.write({'implied_ids': [(4, self.env.ref(field.implied_group).id)]}) + + def execute(self): return { 'type': 'ir.actions.client', diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index 5e059a41cfc..c7281942cf5 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -41,7 +41,8 @@ class PosOrder(models.Model): 'pos_reference': ui_order['name'], 'partner_id': ui_order['partner_id'] or False, 'date_order': ui_order['creation_date'], - 'fiscal_position_id': ui_order['fiscal_position_id'] + 'fiscal_position_id': ui_order['fiscal_position_id'], + 'pricelist_id': ui_order['pricelist_id'], } def _payment_fields(self, ui_paymentline): diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 300e80c928b..3080f901efc 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -62,7 +62,7 @@ exports.PosModel = Backbone.Model.extend({ this.config = null; this.units = []; this.units_by_id = {}; - this.pricelist = null; + this.default_pricelist = null; this.order_sequence = 1; window.posmodel = this; @@ -184,7 +184,9 @@ exports.PosModel = Backbone.Model.extend({ } },{ model: 'res.partner', - fields: ['name','street','city','state_id','country_id','vat','phone','zip','mobile','email','barcode','write_date','property_account_position_id'], + fields: ['name','street','city','state_id','country_id','vat', + 'phone','zip','mobile','email','barcode','write_date', + 'property_account_position_id','property_product_pricelist'], domain: [['customer','=',true]], loaded: function(self,partners){ self.partners = partners; @@ -285,13 +287,46 @@ exports.PosModel = Backbone.Model.extend({ loaded: function(self, locations){ self.shop = locations[0]; }, },{ model: 'product.pricelist', - fields: ['currency_id'], - ids: function(self){ return [self.config.pricelist_id[0]]; }, - loaded: function(self, pricelists){ self.pricelist = pricelists[0]; }, + fields: ['name'], + domain: function(self) { return [['id', 'in', self.config.available_pricelist_ids]]; }, + loaded: function(self, pricelists){ + _.map(pricelists, function (pricelist) { pricelist.items = []; }); + self.default_pricelist = _.findWhere(pricelists, {id: self.config.pricelist_id[0]}); + self.pricelists = pricelists; + }, + },{ + model: 'product.pricelist.item', + domain: function(self) { return [['pricelist_id', 'in', _.pluck(self.pricelists, 'id')]]; }, + loaded: function(self, pricelist_items){ + var pricelist_by_id = {}; + _.each(self.pricelists, function (pricelist) { + pricelist_by_id[pricelist.id] = pricelist; + }); + + _.each(pricelist_items, function (item) { + var pricelist = pricelist_by_id[item.pricelist_id[0]]; + pricelist.items.push(item); + item.base_pricelist = pricelist_by_id[item.base_pricelist_id[0]]; + }); + }, + },{ + model: 'product.category', + fields: ['name', 'parent_id'], + loaded: function(self, product_categories){ + var category_by_id = {}; + _.each(product_categories, function (category) { + category_by_id[category.id] = category; + }); + _.each(product_categories, function (category) { + category.parent = category_by_id[category.parent_id[0]]; + }); + + self.product_categories = product_categories; + }, },{ model: 'res.currency', fields: ['name','symbol','position','rounding'], - ids: function(self){ return [self.pricelist.currency_id[0]]; }, + ids: function(self){ return [self.config.currency_id[0]]; }, loaded: function(self, currencies){ self.currency = currencies[0]; if (self.currency.rounding > 0) { @@ -310,14 +345,17 @@ exports.PosModel = Backbone.Model.extend({ }, },{ model: 'product.product', - fields: ['display_name', 'list_price','price','pos_categ_id', 'taxes_id', 'barcode', 'default_code', - 'to_weight', 'uom_id', 'description_sale', 'description', + fields: ['display_name', 'list_price', 'standard_price', 'categ_id', 'pos_categ_id', 'taxes_id', + 'barcode', 'default_code', 'to_weight', 'uom_id', 'description_sale', 'description', 'product_tmpl_id','tracking'], order: _.map(['sequence','default_code','name'], function (name) { return {name: name}; }), domain: [['sale_ok','=',true],['available_in_pos','=',true]], - context: function(self){ return { pricelist: self.pricelist.id, display_default_code: false }; }, + context: function(self){ return { display_default_code: false }; }, loaded: function(self, products){ - self.db.add_products(products); + self.db.add_products(_.map(products, function (product) { + product.categ = _.findWhere(self.product_categories, {'id': product.categ_id[0]}); + return new exports.Product({}, product); + })); }, },{ model: 'account.bank.statement', @@ -1160,6 +1198,87 @@ exports.load_models = function(models,options) { pmodels.splice.apply(pmodels,[index,0].concat(models)); }; +exports.Product = Backbone.Model.extend({ + initialize: function(attr, options){ + _.extend(this, options); + }, + + // Port of get_product_price on product.pricelist. + // + // Anything related to UOM can be ignored, the POS will always use + // the default UOM set on the product and the user cannot change + // it. + // + // Pricelist items do not have to be sorted. All + // product.pricelist.item records are loaded with a search_read + // and were automatically sorted based on their _order by the + // ORM. After that they are added in this order to the pricelists. + get_price: function(pricelist, quantity){ + var self = this; + var date = moment().startOf('day'); + + var category_ids = []; + var category = this.categ; + while (category) { + category_ids.push(category.id); + category = category.parent; + } + + var pricelist_items = _.filter(pricelist.items, function (item) { + return (! item.product_tmpl_id || item.product_tmpl_id[0] === self.product_tmpl_id) && + (! item.product_id || item.product_id[0] === self.id) && + (! item.categ_id || _.contains(category_ids, item.categ_id[0])) && + (! item.date_start || moment(item.date_start).isSameOrBefore(date)) && + (! item.date_end || moment(item.date_end).isSameOrAfter(date)); + }); + + var price = self.list_price; + _.find(pricelist_items, function (rule) { + if (rule.min_quantity && quantity < rule.min_quantity) { + return false; + } + + if (rule.base === 'pricelist') { + price = self.get_price(rule.base_pricelist, quantity); + } else if (rule.base === 'standard_price') { + price = self.standard_price; + } + + if (rule.compute_price === 'fixed') { + price = rule.fixed_price; + return true; + } else if (rule.compute_price === 'percentage') { + price = price - (price * (rule.percent_price / 100)); + return true; + } else { + var price_limit = price; + price = price - (price * (rule.price_discount / 100)); + if (rule.price_round) { + price = round_pr(price, rule.price_round); + } + if (rule.price_surcharge) { + price += rule.price_surcharge; + } + if (rule.price_min_margin) { + price = Math.max(price, price_limit + rule.price_min_margin); + } + if (rule.price_max_margin) { + price = Math.min(price, price_limit + rule.price_max_margin); + } + return true; + } + + return false; + }); + + // This return value has to be rounded with round_di before + // being used further. Note that this cannot happen here, + // because it would cause inconsistencies with the backend for + // pricelist that have base == 'pricelist'. + return price; + }, +}); + var orderline_id = 1; // An orderline represent one element of the content of a client's shopping cart. @@ -1174,14 +1293,19 @@ exports.Orderline = Backbone.Model.extend({ return; } this.product = options.product; - this.price = options.product.price; this.set_product_lot(this.product); this.set_quantity(1); this.discount = 0; this.discountStr = '0'; this.type = 'unit'; this.selected = false; - this.id = orderline_id++; + this.id = orderline_id++; + + if (options.price) { + this.set_unit_price(options.price); + } else { + this.set_unit_price(this.product.get_price(this.order.pricelist, this.get_quantity())); + } }, init_from_JSON: function(json) { this.product = this.pos.db.get_product_by_id(json.product_id); @@ -1192,7 +1316,7 @@ exports.Orderline = Backbone.Model.extend({ this.set_product_lot(this.product); this.price = json.price_unit; this.set_discount(json.discount); - this.set_quantity(json.qty); + this.set_quantity(json.qty, 'do not recompute unit price'); this.id = json.id; orderline_id = Math.max(this.id+1,orderline_id); var pack_lot_lines = json.pack_lot_ids; @@ -1242,7 +1366,7 @@ exports.Orderline = Backbone.Model.extend({ // sets the quantity of the product. The quantity will be rounded according to the // product's unity of measure properties. Quantities greater than zero will not get // rounded to zero - set_quantity: function(quantity){ + set_quantity: function(quantity, keep_price){ this.order.assert_editable(); if(quantity === 'remove'){ this.order.remove_orderline(this); @@ -1264,7 +1388,11 @@ exports.Orderline = Backbone.Model.extend({ this.quantityStr = '' + this.quantity; } } - this.trigger('change',this); + + // just like in sale.order changing the quantity will recompute the unit price + if(! keep_price){ + this.set_unit_price(this.product.get_price(this.order.pricelist, this.get_quantity())); + } }, // return the quantity of product get_quantity: function(){ @@ -1354,7 +1482,7 @@ exports.Orderline = Backbone.Model.extend({ return false; }else if(this.get_discount() > 0){ // we don't merge discounted orderlines return false; - }else if(this.price !== orderline.price){ + }else if(this.price !== orderline.get_product().get_price(orderline.order.pricelist, this.get_quantity())){ return false; }else if(this.product.tracking == 'lot') { return false; @@ -1778,6 +1906,7 @@ exports.Order = Backbone.Model.extend({ this.paymentlines = new PaymentlineCollection(); this.pos_session_id = this.pos.pos_session.id; this.finalized = false; // if true, cannot be modified. + this.set_pricelist(this.pos.default_pricelist); this.set({ client: null }); @@ -1824,7 +1953,7 @@ exports.Order = Backbone.Model.extend({ var client; this.sequence_number = json.sequence_number; this.pos.pos_session.sequence_number = Math.max(this.sequence_number+1,this.pos.pos_session.sequence_number); - this.session_id = json.pos_session_id; + this.session_id = json.pos_session_id; this.uid = json.uid; this.name = _t("Order ") + this.uid; this.validation_date = json.creation_date; @@ -1841,6 +1970,14 @@ exports.Order = Backbone.Model.extend({ } } + if (json.pricelist_id) { + this.pricelist = _.find(this.pos.pricelists, function (pricelist) { + return pricelist.id === json.pricelist_id; + }); + } else { + this.pricelist = this.pos.default_pricelist; + } + if (json.partner_id) { client = this.pos.db.get_partner_by_id(json.partner_id); if (!client) { @@ -1890,6 +2027,7 @@ exports.Order = Backbone.Model.extend({ lines: orderLines, statement_ids: paymentLines, pos_session_id: this.pos_session_id, + pricelist_id: this.pricelist ? this.pricelist.id : false, partner_id: this.get_client() ? this.get_client().id : false, user_id: this.pos.get_cashier().id, uid: this.uid, @@ -2080,6 +2218,14 @@ exports.Order = Backbone.Model.extend({ this.add_product(tip_product, {quantity: 1, price: tip }); } }, + set_pricelist: function (pricelist) { + var self = this; + this.pricelist = pricelist; + _.each(this.get_orderlines(), function (line) { + line.set_unit_price(line.product.get_price(self.pricelist, line.get_quantity())); + }); + this.trigger('change'); + }, remove_orderline: function( line ){ this.assert_editable(); this.orderlines.remove(line); diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js index ea93a52f1ad..a3293382bd9 100644 --- a/addons/point_of_sale/static/src/js/screens.js +++ b/addons/point_of_sale/static/src/js/screens.js @@ -816,6 +816,14 @@ var ProductListWidget = PosBaseWidget.extend({ this.product_list = options.product_list || []; this.product_cache = new DomCache(); + + this.pos.get('orders').bind('add remove change', function () { + self.renderElement(); + }, this); + + this.pos.bind('change:selectedOrder', function () { + this.renderElement(); + }, this); }, set_product_list: function(product_list){ this.product_list = product_list; @@ -829,20 +837,35 @@ var ProductListWidget = PosBaseWidget.extend({ var target = $target[0]; target.parentNode.replaceChild(this.el,target); }, + calculate_cache_key: function(product, pricelist){ + return product.id + ',' + pricelist.id; + }, + _get_active_pricelist: function(){ + var current_order = this.pos.get_order(); + var current_pricelist = this.pos.default_pricelist; + if (current_order) { + current_pricelist = current_order.pricelist; + } + + return current_pricelist; + }, render_product: function(product){ - var cached = this.product_cache.get_node(product.id); + var current_pricelist = this._get_active_pricelist(); + var cache_key = this.calculate_cache_key(product, current_pricelist); + var cached = this.product_cache.get_node(cache_key); if(!cached){ var image_url = this.get_product_image_url(product); var product_html = QWeb.render('Product',{ widget: this, - product: product, + product: product, + pricelist: current_pricelist, image_url: this.get_product_image_url(product), }); var product_node = document.createElement('div'); product_node.innerHTML = product_html; product_node = product_node.childNodes[1]; - this.product_cache.cache_node(product.id,product_node); + this.product_cache.cache_node(cache_key,product_node); return product_node; } return cached; @@ -1121,15 +1144,14 @@ var ClientListScreenWidget = ScreenWidget.extend({ } }, save_changes: function(){ - var self = this; var order = this.pos.get_order(); if( this.has_client_changed() ){ if ( this.new_client ) { - order.fiscal_position = _.find(this.pos.fiscal_positions, function (fp) { - return fp.id === self.new_client.property_account_position_id[0]; - }); + order.fiscal_position = _.findWhere(this.pos.fiscal_positions, {'id': this.new_client.property_account_position_id[0]}); + order.set_pricelist(_.findWhere(this.pos.pricelists, {'id': this.new_client.property_product_pricelist[0]}) || this.pos.default_pricelist); } else { order.fiscal_position = undefined; + order.set_pricelist(this.pos.default_pricelist); } order.set_client(this.new_client); @@ -2071,6 +2093,64 @@ define_action_button({ }, }); +var set_pricelist_button = ActionButtonWidget.extend({ + template: 'SetPricelistButton', + init: function (parent, options) { + this._super(parent, options); + + this.pos.get('orders').bind('add remove change', function () { + this.renderElement(); + }, this); + + this.pos.bind('change:selectedOrder', function () { + this.renderElement(); + }, this); + }, + button_click: function () { + var self = this; + + var pricelists = _.map(self.pos.pricelists, function (pricelist) { + return { + label: pricelist.name, + item: pricelist + }; + }); + + self.gui.show_popup('selection',{ + title: _t('Select pricelist'), + list: pricelists, + confirm: function (pricelist) { + var order = self.pos.get_order(); + order.set_pricelist(pricelist); + }, + is_selected: function (pricelist) { + return pricelist.id === self.pos.get_order().pricelist.id; + } + }); + }, + get_current_pricelist_name: function () { + var name = _t('Pricelist'); + var order = this.pos.get_order(); + + if (order) { + var pricelist = order.pricelist; + + if (pricelist) { + name = pricelist.display_name; + } + } + return name; + }, +}); + +define_action_button({ + 'name': 'set_pricelist', + 'widget': set_pricelist_button, + 'condition': function(){ + return this.pos.pricelists.length > 1; + }, +}); + return { ReceiptScreenWidget: ReceiptScreenWidget, ActionButtonWidget: ActionButtonWidget, @@ -2087,6 +2167,7 @@ return { ProductCategoriesWidget: ProductCategoriesWidget, ScaleScreenWidget: ScaleScreenWidget, set_fiscal_position_button: set_fiscal_position_button, + set_pricelist_button: set_pricelist_button, }; }); diff --git a/addons/point_of_sale/static/src/js/tests.js b/addons/point_of_sale/static/src/js/tests.js index 0c578088b20..ec6ed65e3fe 100644 --- a/addons/point_of_sale/static/src/js/tests.js +++ b/addons/point_of_sale/static/src/js/tests.js @@ -1,7 +1,212 @@ -odoo.define('point_of_sale.Tour', function (require) { +odoo.define('point_of_sale.tour.pricelist', function (require) { "use strict"; - var tour = require("web_tour.tour"); + var Tour = require('web_tour.tour'); + var rpc = require('web.rpc'); + var utils = require('web.utils'); + var round_di = utils.round_decimals; + + function assert (condition, message) { + if (! condition) { + throw message || "Assertion failed"; + } + } + + function _build_pricelist_context (pricelist, quantity, date) { + return { + pricelist: pricelist.id, + quantity: quantity, + }; + } + + function compare_backend_frontend (product, pricelist_name, quantity) { + return function () { + var pricelist = _.findWhere(posmodel.pricelists, {name: pricelist_name}); + var frontend_price = product.get_price(pricelist, quantity); + // ORM applies digits= on non-stored computed field when + // reading. It does not however truncate like it does when + // storing the field. + frontend_price = round_di(frontend_price, posmodel.dp['Product Price']); + + var context = _build_pricelist_context(pricelist, quantity); + return rpc.query({model: 'product.product', method: 'read', args: [[product.id], ['price']], context: context}) + .then(function (backend_result) { + var debug_info = _.extend(context, { + product: product.id, + product_display_name: product.display_name, + pricelist_name: pricelist.name, + }); + var backend_price = backend_result[0].price; + assert(frontend_price === backend_price, + JSON.stringify(debug_info) + ' DOESN\'T MATCH -> ' + backend_price + ' (backend) != ' + frontend_price + ' (frontend)'); + return (new $.Deferred()).resolve(); + }); + }; + } + + var steps = [{ + content: 'waiting for loading to finish', + trigger: '.o_main_content:has(.loader:hidden)', + run: function () { + var product_boni_orange = posmodel.db.search_product_in_category(0, 'Boni Oranges')[0]; + var product_papillon_orange = posmodel.db.search_product_in_category(0, 'Orange Butterfly')[0]; + var product_citron = posmodel.db.search_product_in_category(0, 'Lemon')[0]; + var product_limon = posmodel.db.search_product_in_category(0, 'Stringers')[0]; + var product_pamplemousse = posmodel.db.search_product_in_category(0, 'Red grapefruit')[0]; + var product_grapes = posmodel.db.search_product_in_category(0, 'Black Grapes')[0]; + var product_external_audit = posmodel.db.search_product_in_category(0, 'External Audit')[0]; + var product_miscellaneous = posmodel.db.search_product_in_category(0, 'Miscellaneous')[0]; + + compare_backend_frontend(product_grapes, 'Public Pricelist', 0, undefined)() + .then(compare_backend_frontend(product_grapes, 'Public Pricelist', 1, undefined)) + .then(compare_backend_frontend(product_grapes, 'Fixed', 1, undefined)) + .then(compare_backend_frontend(product_boni_orange, 'Fixed', 1, undefined)) + .then(compare_backend_frontend(product_papillon_orange, 'Fixed', 1, undefined)) + .then(compare_backend_frontend(product_boni_orange, 'Percentage', 1, undefined)) + .then(compare_backend_frontend(product_papillon_orange, 'Percentage', 1, undefined)) + .then(compare_backend_frontend(product_citron, 'Percentage', 1, undefined)) + .then(compare_backend_frontend(product_boni_orange, 'Formula', 1, undefined)) + .then(compare_backend_frontend(product_papillon_orange, 'Formula', 1, undefined)) + .then(compare_backend_frontend(product_citron, 'Formula', 1, undefined)) + .then(compare_backend_frontend(product_limon, 'Formula', 1, undefined)) + .then(compare_backend_frontend(product_pamplemousse, 'Formula', 1, undefined)) + .then(compare_backend_frontend(product_boni_orange, 'min_quantity ordering', 1, undefined)) + .then(compare_backend_frontend(product_boni_orange, 'min_quantity ordering', 2, undefined)) + .then(compare_backend_frontend(product_grapes, 'Category vs no category', 1, undefined)) + .then(compare_backend_frontend(product_external_audit, 'Category vs no category', 1, undefined)) + .then(compare_backend_frontend(product_grapes, 'Category', 1, undefined)) + .then(compare_backend_frontend(product_external_audit, 'Category', 1, undefined)) + .then(compare_backend_frontend(product_boni_orange, 'Product template', 1, undefined)) + .then(compare_backend_frontend(product_external_audit, 'Product template', 1, undefined)) + .then(compare_backend_frontend(product_boni_orange, 'Dates', 1, undefined)) + .then(compare_backend_frontend(product_miscellaneous, 'Cost base', 1, undefined)) + .then(compare_backend_frontend(product_miscellaneous, 'Pricelist base', 1, undefined)) + .then(compare_backend_frontend(product_miscellaneous, 'Pricelist base 2', 1, undefined)) + .then(compare_backend_frontend(product_papillon_orange, 'Pricelist base rounding', 1, undefined)) + .then(function () { + $('.pos').addClass('done-testing'); + }); + }, + }]; + + steps = steps.concat([{ + content: "wait for unit tests to finish", + trigger: ".pos.done-testing", + run: function () {}, // it's a check + }, { + content: "click pricelist button", + trigger: ".control-button.o_pricelist_button", + }, { + content: "verify default pricelist is set", + trigger: ".selection-item.selected:contains('Public Pricelist')", + run: function () {}, // it's a check + }, { + content: "select fixed pricelist", + trigger: ".selection-item:contains('Fixed')", + }, { + content: "prices should be updated in the product screen", + trigger: ".product:contains('Miscellaneous'):contains('$ 1.00')", + run: function () {}, // it's a check + }, { + content: "open customer list", + trigger: "button.set-customer", + }, { + content: "select agrolait", + trigger: ".client-line:contains('Agrolait')", + }, { + content: "confirm selection", + trigger: ".clientlist-screen .next", + }, { + content: "click pricelist button", + trigger: ".control-button.o_pricelist_button", + }, { + content: "verify pricelist changed", + trigger: ".selection-item.selected:contains('Public Pricelist')", + run: function () {}, // it's a check + }, { + content: "cancel pricelist dialog", + trigger: ".button.cancel:visible", + }, { + content: "prices should be updated in the product screen", + trigger: ".product:contains('Miscellaneous'):contains('$ 18.00')", + run: function () {}, // it's a check + }, { + content: "open customer list", + trigger: "button.set-customer", + }, { + content: "select think big systems", + trigger: ".client-line:contains('Think Big Systems')", + }, { + content: "confirm selection", + trigger: ".clientlist-screen .next", + }, { + content: "click pricelist button", + trigger: ".control-button.o_pricelist_button", + }, { + content: "verify pricelist remained public pricelist ('Not loaded' is not available)", + trigger: ".selection-item.selected:contains('Public Pricelist')", + run: function () {}, // it's a check + }, { + content: "cancel pricelist dialog", + trigger: ".button.cancel:visible", + }, { + content: "click pricelist button", + trigger: ".control-button.o_pricelist_button", + }, { + content: "select fixed pricelist", + trigger: ".selection-item:contains('min_quantity ordering')", + }, { + content: "order 1 kg oranges", + trigger: ".product:contains('Boni Oranges')", + }, { + content: "change qty to 2 kg", + trigger: ".numpad button.input-button:visible:contains('2')", + }, { + content: "verify that unit price of oranges changed to $1", + trigger: ".total > .value:contains('$ 2.00')", + }, { + content: "click pricelist button", + trigger: ".control-button.o_pricelist_button", + }, { + content: "select public pricelist", + trigger: ".selection-item:contains('Public Pricelist')", + }, { + content: "verify that order has been recomputed", + trigger: ".total > .value:contains('$ 3.96')", + }, { + content: "click pricelist button", + trigger: ".control-button.o_pricelist_button", + }, { + content: "select fixed pricelist", + trigger: ".selection-item:contains('min_quantity ordering')", + }, { + content: "order 1 miscellaneous product", + trigger: ".product:contains('Miscellaneous')", + }, { + content: "order 1 miscellaneous product", + trigger: ".product:contains('Miscellaneous')", + }, { + content: "order 1 miscellaneous product", + trigger: ".product:contains('Miscellaneous')", + }, { + content: "verify there is one line with 3 miscellaneous products", + trigger: ".orderline:contains('Miscellaneous') em:contains('3.000')", + run: function () {}, // it's a check + }, { + content: "close the Point of Sale frontend", + trigger: ".header-button", + }, { + content: "confirm closing the frontend", + trigger: ".header-button", + }]); + + Tour.register('pos_pricelist', { test: true, url: '/pos/web' }, steps); +}); + +odoo.define('point_of_sale.tour.acceptance', function (require) { + "use strict"; + + var Tour = require("web_tour.tour"); function add_product_to_order(product_name) { return [{ @@ -113,6 +318,6 @@ odoo.define('point_of_sale.Tour', function (require) { trigger: ".header-button", }]); - tour.register('pos_basic_order', { test: true, url: '/pos/web' }, steps); + Tour.register('pos_basic_order', { test: true, url: '/pos/web' }, steps); }); diff --git a/addons/point_of_sale/static/src/xml/pos.xml b/addons/point_of_sale/static/src/xml/pos.xml index 9b3b5032715..127b05b658a 100644 --- a/addons/point_of_sale/static/src/xml/pos.xml +++ b/addons/point_of_sale/static/src/xml/pos.xml @@ -129,6 +129,12 @@ + +
+ +
+
+
+
+ Pricelist + + + + + N/A + +
@@ -1221,12 +1236,12 @@ - + - + diff --git a/addons/point_of_sale/tests/test_frontend.py b/addons/point_of_sale/tests/test_frontend.py index 1c166f365e4..c69381b0344 100644 --- a/addons/point_of_sale/tests/test_frontend.py +++ b/addons/point_of_sale/tests/test_frontend.py @@ -2,6 +2,8 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo.api import Environment +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT +from datetime import date, timedelta import odoo.tests @@ -12,6 +14,11 @@ class TestUi(odoo.tests.HttpCase): assert cr == self.registry.test_cr env = Environment(cr, self.uid, {}) + # By default parent_store computation is deferred until end of + # tests. Pricelist items however are sorted based on these + # fields, so they need to be computed. + env['product.category']._parent_store_compute() + journal_obj = env['account.journal'] account_obj = env['account.account'] main_company = env.ref('base.main_company') @@ -29,6 +36,216 @@ class TestUi(odoo.tests.HttpCase): 'fields_id': field.id, 'value': 'account.account,' + str(account_receivable.id)}) + fixed_pricelist = env['product.pricelist'].create({ + 'name': 'Fixed', + 'item_ids': [(0, 0, { + 'compute_price': 'fixed', + 'fixed_price': 1, + }), (0, 0, { + 'compute_price': 'fixed', + 'fixed_price': 2, + 'applied_on': '0_product_variant', + 'product_id': env.ref('point_of_sale.boni_orange').id, + }), (0, 0, { + 'compute_price': 'fixed', + 'fixed_price': 13.95, # test for issues like in 7f260ab517ebde634fc274e928eb062463f0d88f + 'applied_on': '0_product_variant', + 'product_id': env.ref('point_of_sale.papillon_orange').id, + })], + }) + + env['product.pricelist'].create({ + 'name': 'Percentage', + 'item_ids': [(0, 0, { + 'compute_price': 'percentage', + 'percent_price': 100, + 'applied_on': '0_product_variant', + 'product_id': env.ref('point_of_sale.boni_orange').id, + }), (0, 0, { + 'compute_price': 'percentage', + 'percent_price': 99, + 'applied_on': '0_product_variant', + 'product_id': env.ref('point_of_sale.papillon_orange').id, + }), (0, 0, { + 'compute_price': 'percentage', + 'percent_price': 0, + 'applied_on': '0_product_variant', + 'product_id': env.ref('point_of_sale.citron').id, + })], + }) + + env['product.pricelist'].create({ + 'name': 'Formula', + 'item_ids': [(0, 0, { + 'compute_price': 'formula', + 'price_discount': 6, + 'price_surcharge': 5, + 'applied_on': '0_product_variant', + 'product_id': env.ref('point_of_sale.boni_orange').id, + }), (0, 0, { + # .99 prices + 'compute_price': 'formula', + 'price_surcharge': -0.01, + 'price_round': 1, + 'applied_on': '0_product_variant', + 'product_id': env.ref('point_of_sale.papillon_orange').id, + }), (0, 0, { + 'compute_price': 'formula', + 'price_min_margin': 10, + 'price_max_margin': 100, + 'applied_on': '0_product_variant', + 'product_id': env.ref('point_of_sale.citron').id, + }), (0, 0, { + 'compute_price': 'formula', + 'price_surcharge': 10, + 'price_max_margin': 5, + 'applied_on': '0_product_variant', + 'product_id': env.ref('point_of_sale.limon').id, + }), (0, 0, { + 'compute_price': 'formula', + 'price_discount': -100, + 'price_min_margin': 5, + 'price_max_margin': 20, + 'applied_on': '0_product_variant', + 'product_id': env.ref('point_of_sale.pamplemousse_rouge_pamplemousse').id, + })], + }) + + env['product.pricelist'].create({ + 'name': 'min_quantity ordering', + 'item_ids': [(0, 0, { + 'compute_price': 'fixed', + 'fixed_price': 1, + 'applied_on': '0_product_variant', + 'min_quantity': 2, + 'product_id': env.ref('point_of_sale.boni_orange').id, + }), (0, 0, { + 'compute_price': 'fixed', + 'fixed_price': 2, + 'applied_on': '0_product_variant', + 'min_quantity': 1, + 'product_id': env.ref('point_of_sale.boni_orange').id, + }), (0, 0, { + 'compute_price': 'fixed', + 'fixed_price': 2, + 'applied_on': '0_product_variant', + 'min_quantity': 2, + 'product_id': env.ref('point_of_sale.product_product_consumable').id, + })], + }) + + env['product.pricelist'].create({ + 'name': 'Product template', + 'item_ids': [(0, 0, { + 'compute_price': 'fixed', + 'fixed_price': 1, + 'applied_on': '1_product', + 'product_tmpl_id': env.ref('point_of_sale.boni_orange_product_template').id, + }), (0, 0, { + 'compute_price': 'fixed', + 'fixed_price': 2, + })], + }) + + env['product.pricelist'].create({ + # no category has precedence over category + 'name': 'Category vs no category', + 'item_ids': [(0, 0, { + 'compute_price': 'fixed', + 'fixed_price': 1, + 'applied_on': '2_product_category', + 'categ_id': env.ref('product.product_category_3').id, # All / Saleable / Services + }), (0, 0, { + 'compute_price': 'fixed', + 'fixed_price': 2, + })], + }) + + p = env['product.pricelist'].create({ + 'name': 'Category', + 'item_ids': [(0, 0, { + 'compute_price': 'fixed', + 'fixed_price': 2, + 'applied_on': '2_product_category', + 'categ_id': env.ref('product.product_category_all').id, + }), (0, 0, { + 'compute_price': 'fixed', + 'fixed_price': 1, + 'applied_on': '2_product_category', + 'categ_id': env.ref('product.product_category_3').id, # All / Saleable / Services + })], + }) + + today = date.today() + one_week_ago = today - timedelta(weeks=1) + two_weeks_ago = today - timedelta(weeks=2) + one_week_from_now = today + timedelta(weeks=1) + two_weeks_from_now = today + timedelta(weeks=2) + + env['product.pricelist'].create({ + 'name': 'Dates', + 'item_ids': [(0, 0, { + 'compute_price': 'fixed', + 'fixed_price': 1, + 'date_start': two_weeks_ago.strftime(DEFAULT_SERVER_DATE_FORMAT), + 'date_end': one_week_ago.strftime(DEFAULT_SERVER_DATE_FORMAT), + }), (0, 0, { + 'compute_price': 'fixed', + 'fixed_price': 2, + 'date_start': today.strftime(DEFAULT_SERVER_DATE_FORMAT), + 'date_end': one_week_from_now.strftime(DEFAULT_SERVER_DATE_FORMAT), + }), (0, 0, { + 'compute_price': 'fixed', + 'fixed_price': 3, + 'date_start': one_week_from_now.strftime(DEFAULT_SERVER_DATE_FORMAT), + 'date_end': two_weeks_from_now.strftime(DEFAULT_SERVER_DATE_FORMAT), + })], + }) + + cost_base_pricelist = env['product.pricelist'].create({ + 'name': 'Cost base', + 'item_ids': [(0, 0, { + 'base': 'standard_price', + 'compute_price': 'percentage', + 'percent_price': 55, + })], + }) + + pricelist_base_pricelist = env['product.pricelist'].create({ + 'name': 'Pricelist base', + 'item_ids': [(0, 0, { + 'base': 'pricelist', + 'base_pricelist_id': cost_base_pricelist.id, + 'compute_price': 'percentage', + 'percent_price': 15, + })], + }) + + env['product.pricelist'].create({ + 'name': 'Pricelist base 2', + 'item_ids': [(0, 0, { + 'base': 'pricelist', + 'base_pricelist_id': pricelist_base_pricelist.id, + 'compute_price': 'percentage', + 'percent_price': 3, + })], + }) + + env['product.pricelist'].create({ + 'name': 'Pricelist base rounding', + 'item_ids': [(0, 0, { + 'base': 'pricelist', + 'base_pricelist_id': fixed_pricelist.id, + 'compute_price': 'percentage', + 'percent_price': 0.01, + })], + }) + + excluded_pricelist = env['product.pricelist'].create({ + 'name': 'Not loaded' + }) + env.ref('base.res_partner_18').property_product_pricelist = excluded_pricelist + # set the company currency to USD, otherwise it will assume # euro's. this will cause issues as the sales journal is in # USD, because of this all products would have a different @@ -40,7 +257,8 @@ class TestUi(odoo.tests.HttpCase): 'type': 'sale', 'company_id': main_company.id}) - env['product.pricelist'].search([]).write(dict(currency_id=main_company.currency_id.id)) + all_pricelists = env['product.pricelist'].search([('id', '!=', excluded_pricelist.id)]) + all_pricelists.write(dict(currency_id=main_company.currency_id.id)) main_pos_config.write({ 'journal_id': test_sale_journal.id, @@ -49,7 +267,9 @@ class TestUi(odoo.tests.HttpCase): 'code': 'TSC', 'type': 'cash', 'company_id': main_company.id, - 'journal_user': True})]}) + 'journal_user': True})], + 'available_pricelist_ids': [(4, pricelist.id) for pricelist in all_pricelists], + }) # open a session, the /pos/web controller will redirect to it main_pos_config.open_session_cb() @@ -61,6 +281,11 @@ class TestUi(odoo.tests.HttpCase): env['ir.module.module'].search([('name', '=', 'point_of_sale')], limit=1).state = 'installed' cr.release() + self.phantom_js("/pos/web", + "odoo.__DEBUG__.services['web_tour.tour'].run('pos_pricelist')", + "odoo.__DEBUG__.services['web_tour.tour'].tours.pos_pricelist.ready", + login="admin") + self.phantom_js("/pos/web", "odoo.__DEBUG__.services['web_tour.tour'].run('pos_basic_order')", "odoo.__DEBUG__.services['web_tour.tour'].tours.pos_basic_order.ready", 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 b621d5ba16f..af5b4e93373 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 @@ -481,6 +481,7 @@ class TestPointOfSaleFlow(TestPointOfSaleCommon): 'amount_total': untax + atax, 'creation_date': fields.Datetime.now(), 'fiscal_position_id': False, + 'pricelist_id': self.pos_config.available_pricelist_ids[0].id, 'lines': [[0, 0, {'discount': 0, @@ -514,6 +515,7 @@ class TestPointOfSaleFlow(TestPointOfSaleCommon): 'amount_total': untax + atax, 'creation_date': fields.Datetime.now(), 'fiscal_position_id': False, + 'pricelist_id': self.pos_config.available_pricelist_ids[0].id, 'lines': [[0, 0, {'discount': 0, @@ -547,6 +549,7 @@ class TestPointOfSaleFlow(TestPointOfSaleCommon): 'amount_total': untax + atax, 'creation_date': fields.Datetime.now(), 'fiscal_position_id': False, + 'pricelist_id': self.pos_config.available_pricelist_ids[0].id, 'lines': [[0, 0, {'discount': 0, diff --git a/addons/point_of_sale/views/pos_config_view.xml b/addons/point_of_sale/views/pos_config_view.xml index 43743694b69..288f6e8bb16 100644 --- a/addons/point_of_sale/views/pos_config_view.xml +++ b/addons/point_of_sale/views/pos_config_view.xml @@ -249,8 +249,13 @@ Set shop-specific prices, seasonal discounts, etc.
-
- +
+
+
+