diff --git a/pos_pricelist/README.rst b/pos_pricelist/README.rst new file mode 100644 index 00000000..5bfdd480 --- /dev/null +++ b/pos_pricelist/README.rst @@ -0,0 +1,45 @@ +Dynamic Price for Odoo Point of Sale +------------------------------------ + +************* +Motivation : +************* + +Many issues report this feature. This why I took decision to start this module + +Reported issues : + +`odoo 8 POS price list discount has no effect. `_ + +`ODOO POS Pricelist - Public Price & Discounted Price in Receipt. `_ + +`V8.0 pos gives wrong price when using min qty in pos pricelist `_ + +******************** +Goal of the module : +******************** + +The goal of this module is to bring the pricelist computation engine to the POS. +This module loads all the necessary data into the POS in order to have a coherent behaviour (offline/online/backend). + +********************** +Implemented features : +********************** + +1. Attached pricelist on partner will take effect on the POS, which means that if we attach a pricelist to a partner. +The POS will recognize it and will compute the price according to the rule defined. + +2. Fiscal Position of each partner will also be present so taxes will be correctly computed +(conforming to the fiscal position). + +- Implemented Rules are : + +1. (-1) : Rule based on other pricelist +2. (-2) : Rule based on supplierinfo +3. (default) : Any price type which is set on the product form + +****************** +Missing features : +****************** + +- As you may know, product template is not fully implemented in the POS, so I decided to drop it from this module. \ No newline at end of file diff --git a/pos_pricelist/__init__.py b/pos_pricelist/__init__.py new file mode 100644 index 00000000..6d755589 --- /dev/null +++ b/pos_pricelist/__init__.py @@ -0,0 +1,18 @@ +# -#- coding: utf-8 -#- +############################################################################## +# Point Of Sale - Pricelist for POS Odoo +# Copyright (C) 2014 Taktik (http://www.taktik.be) +# @author Adil Houmadi +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## diff --git a/pos_pricelist/__openerp__.py b/pos_pricelist/__openerp__.py new file mode 100644 index 00000000..01d14e64 --- /dev/null +++ b/pos_pricelist/__openerp__.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +############################################################################## +# Point Of Sale - Pricelist for POS Odoo +# Copyright (C) 2014 Taktik (http://www.taktik.be) +# @author Adil Houmadi +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## +{ + 'name': 'POS Pricelist', + 'version': '1.0.0', + 'category': 'Point Of Sale', + 'sequence': 1, + 'author': 'Adil Houmadi @Taktik', + 'summary': 'Pricelist for Point of sale', + 'description': """ +New feature for the Point Of Sale: +============================================= + Add support for pricelist on the point of sale + """, + 'depends': [ + "point_of_sale", + ], + 'data': [ + "views/pos_pricelist_template.xml", + ], + 'demo': [ + 'demo/pos_pricelist_demo.yml', + ], + 'installable': True, + 'application': False, + 'auto_install': False, +} diff --git a/pos_pricelist/demo/pos_pricelist_demo.yml b/pos_pricelist/demo/pos_pricelist_demo.yml new file mode 100644 index 00000000..4c2e9888 --- /dev/null +++ b/pos_pricelist/demo/pos_pricelist_demo.yml @@ -0,0 +1,95 @@ +- + This product will have two rule (min_qty:3 => 10%, min_qty:5 => 30%) +- + !record {model: product.product, id: pos_product_product_1}: + default_code: ABC123 + name: POS Product 1 + type: product + categ_id: product.product_category_1 + list_price: 100.0 + standard_price: 50.0 + uom_id: product.product_uom_unit + uom_po_id: product.product_uom_unit + available_in_pos: True + +- + This product will have one rule (min_qty:2 => 10%) +- + !record {model: product.product, id: pos_product_product_2}: + default_code: ABC124 + name: POS Product 2 + type: product + categ_id: product.product_category_1 + list_price: 100.0 + standard_price: 100.0 + uom_id: product.product_uom_unit + uom_po_id: product.product_uom_unit + available_in_pos: True + +- + This product will have a rule that (based on supplier discount) +- + !record {model: product.product, id: pos_product_product_3}: + default_code: ABC125 + name: POS Product 3 + type: product + categ_id: product.product_category_1 + list_price: 100.0 + standard_price: 50.0 + uom_id: product.product_uom_unit + uom_po_id: product.product_uom_unit + available_in_pos: True + seller_ids: + - delay: 1 + name: base.res_partner_3 + pricelist_ids: + - min_quantity : 2.0 + price : 80 + +- + This product belgon to computer category (5% dicount) +- + !record {model: product.product, id: pos_product_product_4}: + default_code: ABC125 + name: POS Product 4 + type: product + categ_id: product.product_category_4 + list_price: 100.0 + standard_price: 50.0 + uom_id: product.product_uom_unit + uom_po_id: product.product_uom_unit + available_in_pos: True +- + Prepare pricelist items +- + !record {model: product.pricelist.version, id: product.ver0}: + items_id: + - name: 10% Discount on POS Product 1 (Qty 3) + sequence: 2 + product_id: pos_product_product_1 + base: 1 + price_discount: -0.10 + min_quantity: 3 + - name: 30% Discount on POS Product 1 (Qty 5) + sequence: 1 + product_id: pos_product_product_1 + min_quantity: 5 + base: 1 + price_discount: -0.30 + - name: 10% Discount (POS Product 2) + sequence: 1 + product_id: pos_product_product_2 + base: 2 + price_discount: -0.10 + min_quantity: 2 + - name: 20% Discount given by supplier + sequence: 1 + min_quantity: 2 + product_id: pos_product_product_3 + base: -2 + - name: 5% Discount on all Computer related products (Qty 2) + sequence: 1 + min_quantity: 2 + base: 1 + categ_id: product.product_category_4 + price_discount: -0.05 \ No newline at end of file diff --git a/pos_pricelist/static/src/css/style.css b/pos_pricelist/static/src/css/style.css new file mode 100644 index 00000000..5a652bb9 --- /dev/null +++ b/pos_pricelist/static/src/css/style.css @@ -0,0 +1,46 @@ +/****************************************************************************** +* Point Of Sale - Pricelist for POS Odoo +* Copyright (C) 2014 Taktik (http://www.taktik.be) +* @author Adil Houmadi +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see . +* +******************************************************************************/ +.popover { + position: absolute; + top: 0; + left: 0; + z-index: 1060; + display: none; + max-width: 276px; + padding: 1px; + text-align: left; + white-space: normal; + background-color: #fff; + -webkit-background-clip: padding-box; + background-clip: padding-box; + border: 1px solid #ccc; + border: 1px solid rgba(0, 0, 0, .2); + border-radius: 6px; + -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, .2); + box-shadow: 0 5px 10px rgba(0, 0, 0, .2); +} +.popover-title { + padding: 8px 14px; + margin: 0; + font-size: 14px; + font-weight: normal; + line-height: 18px; + background-color: #f7f7f7; + border-bottom: 1px solid #ebebeb; + border-radius: 5px 5px 0 0; +} \ No newline at end of file diff --git a/pos_pricelist/static/src/js/db.js b/pos_pricelist/static/src/js/db.js new file mode 100644 index 00000000..d00f6ef9 --- /dev/null +++ b/pos_pricelist/static/src/js/db.js @@ -0,0 +1,155 @@ +/****************************************************************************** +* Point Of Sale - Pricelist for POS Odoo +* Copyright (C) 2014 Taktik (http://www.taktik.be) +* @author Adil Houmadi +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see . +* +******************************************************************************/ +function pos_pricelist_db(instance, module) { + + module.PosDB = module.PosDB.extend({ + init: function (options) { + options = options || {}; + this._super(options); + this.default_pricelist_id = 0; + this.pricelist_by_id = {}; + this.pricelist_version_by_id = {}; + this.pricelist_item_by_id = {}; + this.pricelist_item_sorted = []; + this.product_category_by_id = {}; + this.product_category_children = {}; + this.product_category_ancestors = {}; + this.product_price_type_by_id = {}; + this.supplierinfo_by_id = {}; + this.pricelist_partnerinfo_by_id = {}; + this.fiscal_position_tax_by_id = {}; + }, + add_fiscal_position_taxes: function (fiscal_position_taxes) { + if (!(fiscal_position_taxes instanceof Array)) { + fiscal_position_taxes = [fiscal_position_taxes]; + } + var fiscal_position_tax; + while (fiscal_position_tax = fiscal_position_taxes.pop()) { + this.fiscal_position_tax_by_id[fiscal_position_tax.id] = fiscal_position_tax; + } + }, + add_pricelist_partnerinfo: function (pricelist_partnerinfos) { + if (!(pricelist_partnerinfos instanceof Array)) { + pricelist_partnerinfos = [pricelist_partnerinfos]; + } + var partner_info; + while (partner_info = pricelist_partnerinfos.pop()) { + this.pricelist_partnerinfo_by_id[partner_info.id] = partner_info; + } + }, + add_supplierinfo: function (supplierinfos) { + if (!(supplierinfos instanceof Array)) { + supplierinfos = [supplierinfos]; + } + var supplier_info; + while (supplier_info = supplierinfos.pop()) { + this.supplierinfo_by_id[supplier_info.id] = supplier_info; + } + }, + add_default_pricelist: function (res_id) { + if (res_id && res_id.length) { + this.default_pricelist_id = res_id[0].res_id; + } + }, + add_pricelists: function (pricelists) { + if (!(pricelists instanceof Array)) { + pricelists = [pricelists]; + } + var pricelist; + while (pricelist = pricelists.pop()) { + this.pricelist_by_id[pricelist.id] = pricelist; + } + }, + add_pricelist_versions: function (versions) { + if (!(versions instanceof Array)) { + versions = [versions]; + } + var version; + while (version = versions.pop()) { + this.pricelist_version_by_id[version.id] = version; + } + }, + add_pricelist_items: function (items) { + if (!(items instanceof Array)) { + items = [items]; + } + var item; + while (item = items.pop()) { + this.pricelist_item_by_id[item.id] = item; + } + this.pricelist_item_sorted = this._items_sorted(); + }, + add_price_types: function (price_types) { + if (!(price_types instanceof Array)) { + price_types = [price_types]; + } + var ptype; + while (ptype = price_types.pop()) { + this.product_price_type_by_id[ptype.id] = ptype; + } + }, + add_product_categories: function (categories) { + if (!(categories instanceof Array)) { + categories = [categories]; + } + var category; + while (category = categories.pop()) { + this.product_category_by_id[category.id] = category; + this.product_category_children[category.id] = category.child_id + } + this._make_ancestors(); + }, + _make_ancestors: function () { + var category, ancestors; + for (var id in this.product_category_by_id) { + category = this.product_category_by_id[id]; + ancestors = []; + while (category.parent_id) { + ancestors.push(category.parent_id[0]); + category = category.parent_id ? this.product_category_by_id[category.parent_id[0]] : false; + } + this.product_category_ancestors[parseInt(id)] = ancestors; + } + }, + _items_sorted: function () { + var items = this.pricelist_item_by_id; + var list = []; + for (var key in items) { + list.push(items[key]); + } + list.sort(function (a, b) { + if (a.sequence < b.sequence) return -1; + if (a.sequence > b.sequence) return 1; + if (a.min_quantity > b.min_quantity) return -1; + if (a.min_quantity < b.min_quantity) return 1; + return 0; + }); + return list; + }, + find_taxes_by_fiscal_position_id: function (fiscal_position_id) { + var taxes = []; + for (var id in this.fiscal_position_tax_by_id) { + var tax = this.fiscal_position_tax_by_id[id]; + if (tax && tax.position_id && tax.position_id[0] == fiscal_position_id) { + taxes.push(tax); + } + } + return taxes; + } + }) +} diff --git a/pos_pricelist/static/src/js/main.js b/pos_pricelist/static/src/js/main.js new file mode 100644 index 00000000..143d36e0 --- /dev/null +++ b/pos_pricelist/static/src/js/main.js @@ -0,0 +1,24 @@ +/****************************************************************************** +* Point Of Sale - Pricelist for POS Odoo +* Copyright (C) 2014 Taktik (http://www.taktik.be) +* @author Adil Houmadi +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see . +* +******************************************************************************/ +openerp.pos_pricelist = function (instance) { + var module = instance.point_of_sale; + pos_pricelist_db(instance, module); + pos_pricelist_models(instance, module); + pos_pricelist_screens(instance, module); + pos_pricelist_widgets(instance, module); +}; diff --git a/pos_pricelist/static/src/js/models.js b/pos_pricelist/static/src/js/models.js new file mode 100644 index 00000000..a2f11bff --- /dev/null +++ b/pos_pricelist/static/src/js/models.js @@ -0,0 +1,631 @@ +/****************************************************************************** +* Point Of Sale - Pricelist for POS Odoo +* Copyright (C) 2014 Taktik (http://www.taktik.be) +* @author Adil Houmadi +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see . +* +******************************************************************************/ +function pos_pricelist_models(instance, module) { + + var _t = instance.web._t; + var round_pr = instance.web.round_precision; + var round_di = instance.web.round_decimals; + + /** + * @param funcName + * @returns {*} + * @private + */ + Backbone.Model.prototype._super = function (funcName) { + return this.constructor.__super__[funcName].apply(this, _.rest(arguments)); + }; + + /** + * Extend the POS model + */ + module.PosModel = module.PosModel.extend({ + initialize: function (session, attributes) { + this._super('initialize', session, attributes); + this.pricelist_engine = new module.PricelistEngine({'pos': this, 'db': this.db, 'pos_widget': this.pos_widget}); + arrange_elements(this); + }, + /** + * find model based on name + * @param model_name + * @returns {{}} + */ + find_model: function (model_name) { + var self = this; + var lookup = {}; + for (var i = 0, len = self.models.length; i < len; i++) { + if (self.models[i].model === model_name) { + lookup[i] = self.models[i] + } + } + return lookup + }, + on_removed_order: function (removed_order, index, reason) { + this._super('on_removed_order', removed_order, index, reason); + if ((reason === 'abandon' || removed_order.temporary) && this.get('orders').size() > 0) { + var current_order = (this.get('orders').at(index) || this.get('orders').last()); + var partner = current_order.get_client() ? current_order.get_client() : false; + this.pricelist_engine.update_products_ui(partner); + } + } + }); + + /** + * Extend the order + */ + module.Order = module.Order.extend({ + /** + * override this method to merge lines + * TODO : Need some refactoring in the standard POS to Do it better + * TODO : from line 73 till 85, we need to move this to another method + * @param product + * @param options + */ + addProduct: function (product, options) { + options = options || {}; + var attr = JSON.parse(JSON.stringify(product)); + attr.pos = this.pos; + attr.order = this; + var line = new module.Orderline({}, {pos: this.pos, order: this, product: product}); + var self = this; + var found = false; + + if (options.quantity !== undefined) { + line.set_quantity(options.quantity); + } + if (options.price !== undefined) { + line.set_unit_price(options.price); + } + if (options.discount !== undefined) { + line.set_discount(options.discount); + } + + var orderlines = []; + if (self.get('orderLines').models !== undefined) { + orderlines = self.get('orderLines').models; + } + for (var i = 0; i < orderlines.length; i++) { + var _line = orderlines[i]; + if (_line && _line.can_be_merged_with(line) && options.merge !== false) { + _line.merge(line); + found = true; + break; + } + } + if (!found) { + this.get('orderLines').add(line); + } + this.selectLine(this.getLastOrderline()); + } + }); + + /** + * Extend the Order line + */ + module.Orderline = module.Orderline.extend({ + initialize: function (attr, options) { + this._super('initialize', attr, options); + this.manuel_price = false; + if (options.product !== undefined) { + var qty = this.compute_qty(options.order, options.product); + var partner = options.order.get_client(); + var product = options.product; + var db = this.pos.db; + var price = this.pos.pricelist_engine.compute_price_all(db, product, partner, qty); + if (price !== false && price !== 0.0) { + this.price = price; + } + } + }, + /** + * @param state + */ + set_manuel_price: function (state) { + this.manuel_price = state; + }, + /** + * @param quantity + */ + set_quantity: function (quantity) { + this._super('set_quantity', quantity); + var partner = this.order.get_client(); + var product = this.product; + var db = this.pos.db; + var price = this.pos.pricelist_engine.compute_price_all(db, product, partner, quantity); + if (price !== false && price !== 0.0) { + this.price = price; + } + this.trigger('change', this); + }, + /** + * override this method to take fiscal positions in consideration + * get all price + * TODO : find a better way to do it : need some refactoring in the pos standard + * @returns {{priceWithTax: *, priceWithoutTax: *, tax: number, taxDetails: {}}} + */ + get_all_prices: function () { + + var self = this; + var currency_rounding = this.pos.currency.rounding; + var base = this.get_base_price(); + var totalTax = base; + var totalNoTax = base; + var product = this.get_product(); + var taxes = this.get_applicable_taxes(); + var taxtotal = 0; + var taxdetail = {}; + + // Add by pos_pricelist + var partner = this.order.get_client(); + var fiscal_position_taxes = []; + if (partner && partner.property_account_position) { + fiscal_position_taxes = self.pos.db.find_taxes_by_fiscal_position_id(partner.property_account_position[0]); + } + var product_taxes = []; + for (var i = 0, ilen = fiscal_position_taxes.length; i < ilen; i++) { + var fp_tax = fiscal_position_taxes[i]; + for (var j = 0, jlen = taxes.length; j < jlen; j++) { + var p_tax = taxes[j]; + if (fp_tax && p_tax && fp_tax.tax_src_id[0] === p_tax.id) { + var dest_tax = _.detect(this.pos.taxes, function (t) { + return t.id === fp_tax.tax_dest_id[0]; + }); + product_taxes.push(dest_tax); + } + } + } + if (product_taxes.length === 0) { + for (var i = 0, ilen = product.taxes_id; i < ilen; i++) { + var _id = product.taxes_id[i]; + var p_tax = _.detect(this.pos.taxes, function (t) { + return t.id === _id; + }); + product_taxes.push(p_tax); + } + } + _.each(product_taxes, function (tax) { + if (tax.price_include) { + var tmp; + if (tax.type === "percent") { + tmp = base - round_pr(base / (1 + tax.amount), currency_rounding); + } else if (tax.type === "fixed") { + tmp = round_pr(tax.amount * self.get_quantity(), currency_rounding); + } else { + throw "This type of tax is not supported by the point of sale: " + tax.type; + } + tmp = round_pr(tmp, currency_rounding); + taxtotal += tmp; + totalNoTax -= tmp; + taxdetail[tax.id] = tmp; + } else { + var tmp; + if (tax.type === "percent") { + tmp = tax.amount * base; + } else if (tax.type === "fixed") { + tmp = tax.amount * self.get_quantity(); + } else { + throw "This type of tax is not supported by the point of sale: " + tax.type; + } + tmp = round_pr(tmp, currency_rounding); + if (tax.include_base_amount) { + base += tmp; + } + taxtotal += tmp; + totalTax += tmp; + taxdetail[tax.id] = tmp; + } + }); + return { + "priceWithTax": totalTax, + "priceWithoutTax": totalNoTax, + "tax": taxtotal, + "taxDetails": taxdetail + }; + }, + /** + * Override this method to avoid a return false if the price is different + * Check super method : (this.price !== orderline.price) is not necessary in our case + * @param orderline + * @returns {boolean} + */ + can_be_merged_with: function (orderline) { + var result = this._super('can_be_merged_with', orderline); + if (!result) { + if (!this.manuel_price) { + return (this.get_product().id === orderline.get_product().id); + } else { + return false; + } + } + return true; + }, + /** + * Override to set price + * @param orderline + */ + merge: function (orderline) { + this._super('merge', orderline); + this.set_unit_price(orderline.price); + }, + /** + * @param order + * @param product + * @returns {number} + */ + compute_qty: function (order, product) { + var qty = 1; + var orderlines = []; + if (order.get('orderLines').models !== undefined) { + orderlines = order.get('orderLines').models; + } + for (var i = 0; i < orderlines.length; i++) { + if (orderlines[i].product.id === product.id && !orderlines[i].manuel_price) { + qty += orderlines[i].quantity; + } + } + return qty; + }, + }); + + /** + * Pricelist Engine to compute price + */ + module.PricelistEngine = instance.web.Class.extend({ + init: function(options){ + options = options || {}; + this.pos = options.pos; + this.db = options.db; + this.pos_widget = options.pos_widget; + }, + /** + * compute price for all price list + * @param db + * @param product + * @param partner + * @param qty + * @returns {*} + */ + compute_price_all: function (db, product, partner, qty) { + var price_list_id = false; + if (partner && partner.property_product_pricelist) { + price_list_id = partner.property_product_pricelist[0]; + } else { + price_list_id = db.default_pricelist_id; + } + return this.compute_price(db, product, partner, qty, parseInt(price_list_id)); + }, + /** + * loop find a valid version for the price list id given in param + * @param db + * @param pricelist_id + * @returns {boolean} + */ + find_valid_pricelist_version: function (db, pricelist_id) { + var date = new Date(); + var version = false; + var pricelist = db.pricelist_by_id[pricelist_id]; + for (var i = 0, len = pricelist.version_id.length; i < len; i++) { + var v = db.pricelist_version_by_id[pricelist.version_id[i]]; + if (((v.date_start == false) || (new Date(v.date_start) <= date)) && + ((v.date_end == false) || (new Date(v.date_end) >= date))) { + version = v; + break; + } + } + return version; + }, + /** + * compute the price for the given product + * @param database + * @param product + * @param partner + * @param qty + * @param pricelist_id + * @returns {boolean} + */ + compute_price: function (database, product, partner, qty, pricelist_id) { + + var self = this; + var db = database; + + // get a valid version + var version = this.find_valid_pricelist_version(db, pricelist_id); + if (version == false) { + var message = _t('Pricelist Error'); + var comment = _t('At least one pricelist has no active version ! Please create or activate one.'); + show_error(this, message, comment); + return false; + } + + // get categories + var categ_ids = []; + if (product.categ_id) { + categ_ids.push(product.categ_id[0]); + categ_ids = categ_ids.concat(db.product_category_ancestors[product.categ_id[0]]); + } + + // find items + var items = [], i, len; + for (i = 0, len = db.pricelist_item_sorted.length; i < len; i++) { + var item = db.pricelist_item_sorted[i]; + if ((item.product_id === false || item.product_id[0] === product.id) && + (item.categ_id === false || categ_ids.indexOf(item.categ_id[0]) !== -1) && + (item.price_version_id[0] === version.id)) { + items.push(item); + } + } + + var results = {}; + results[product.id] = 0.0; + var price_types = {}; + var price = false; + + // loop through items + for (i = 0, len = items.length; i < len; i++) { + var rule = items[i]; + + if (rule.min_quantity && qty < rule.min_quantity) { + continue; + } + if (rule.product_id && rule.product_id[0] && product.id != rule.product_id[0]) { + continue; + } + if (rule.categ_id) { + var cat_id = product.categ_id[0]; + while (cat_id) { + if (cat_id == rule.categ_id[0]) { + break; + } + cat_id = db.product_category_by_id[cat_id].parent_id[0]; + } + if (!(cat_id)) { + continue; + } + } + // Based on field + switch (rule.base) { + case -1: + if (rule.base_pricelist_id) { + price = self.compute_price(db, product, false, qty, rule.base_pricelist_id[0]); + } + break; + case -2: + var seller = false; + for (var index in product.seller_ids) { + var seller_id = product.seller_ids[index]; + var _tmp_seller = db.supplierinfo_by_id[seller_id]; + if ((!partner) || (_tmp_seller.name.length && _tmp_seller.name[0] != partner.name)) + continue; + seller = _tmp_seller + } + if (!seller && product.seller_ids) { + seller = db.supplierinfo_by_id[product.seller_ids[0]]; + } + if (seller) { + for (var _id in seller.pricelist_ids) { + var info_id = seller.pricelist_ids[_id]; + var line = db.pricelist_partnerinfo_by_id[info_id]; + if (line.min_quantity <= qty) { + price = line.price + } + } + } + break; + default: + if (!price_types.hasOwnProperty(rule.base)) { + price_types[rule.base] = db.product_price_type_by_id[rule.base]; + } + var price_type = price_types[rule.base]; + if (db.product_by_id[product.id].hasOwnProperty(price_type.field)) { + price = db.product_by_id[product.id][price_type.field]; + } + } + if (price !== false) { + var price_limit = price; + price = price * (1.0 + (rule['price_discount'] ? rule['price_discount'] : 0.0)) + if (rule['price_round']) { + price = parseFloat(price.toFixed(Math.ceil(Math.log(1.0 / rule['price_round']) / Math.log(10)))); + } + price += (rule['price_surcharge'] ? rule['price_surcharge'] : 0.0); + 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_min_margin']) + } + } + break; + } + return price + }, + /** + * @param partner + */ + update_products_ui: function (partner) { + var db = this.db; + if(!this.pos_widget.product_screen) return; + var product_list_ui = this.pos_widget.product_screen.$('.product-list span.product'); + for (var i = 0, len = product_list_ui.length; i < len; i++) { + var product_ui = product_list_ui[i]; + var product_id = $(product_ui).data('product-id'); + var product = db.get_product_by_id(product_id); + var price = this.compute_price_all(db, product, partner, 1); + if (price !== false && price !== 0.0) { + price = round_di(parseFloat(price) || 0, this.pos.dp['Product Price']); + price = this.pos_widget.format_currency(price); + $(product_ui).find('.price-tag').html(price); + } + } + }, + /** + * + * @param partner + * @param orderLines + */ + update_ticket: function (partner, orderLines) { + var db = this.db; + for (var i = 0, len = orderLines.length; i < len; i++) { + var line = orderLines[i]; + var product = line.product; + var quantity = line.quantity; + var price = this.compute_price_all(db, product, partner, quantity); + if (price !== false && price !== 0.0) { + line.price = price; + } + line.trigger('change', line); + } + } + }); + + /** + * show error + * @param context + * @param message + * @param comment + */ + function show_error(context, message, comment) { + context.pos.pos_widget.screen_selector.show_popup('error', { + 'message': message, + 'comment': comment + }); + } + + /** + * patch models to load some entities + * @param pos_model + */ + function arrange_elements(pos_model) { + + var product_model = pos_model.find_model('product.product'); + if (_.size(product_model) == 1) { + var product_index = parseInt(Object.keys(product_model)[0]); + pos_model.models[product_index].fields.push('categ_id', 'seller_ids'); + } + + var res_product_pricelist = pos_model.find_model('product.pricelist'); + if (_.size(res_product_pricelist) == 1) { + var pricelist_index = parseInt(Object.keys(res_product_pricelist)[0]); + pos_model.models.splice(++pricelist_index, 0, + { + model: 'account.fiscal.position.tax', + fields: ['display_name', 'position_id', 'tax_src_id', 'tax_dest_id'], + domain: null, + loaded: function (self, fiscal_position_taxes) { + self.db.add_fiscal_position_taxes(fiscal_position_taxes); + } + }, + { + model: 'pricelist.partnerinfo', + fields: ['display_name', 'min_quantity', 'name', 'price', 'suppinfo_id'], + domain: null, + loaded: function (self, pricelist_partnerinfos) { + self.db.add_pricelist_partnerinfo(pricelist_partnerinfos); + } + }, + { + model: 'product.supplierinfo', + fields: ['delay', 'name', 'min_qty', 'pricelist_ids', 'product_code', 'product_name', 'sequence', + 'qty', 'product_tmpl_id'], + domain: null, + loaded: function (self, supplierinfos) { + self.db.add_supplierinfo(supplierinfos); + } + }, + { + model: 'product.category', + fields: ['name', 'display_name', 'parent_id', 'child_id'], + domain: null, + loaded: function (self, categories) { + self.db.add_product_categories(categories); + + } + }, + { + model: 'ir.model.data', + fields: ['res_id'], + domain: function () { + return [ + ['module', '=', 'product'], + ['name', '=', 'property_product_pricelist'] + ] + }, + loaded: function (self, res) { + self.db.add_default_pricelist(res); + } + }, + { + model: 'product.pricelist', + fields: ['display_name', 'name', 'version_id', 'currency_id'], + domain: function () { + return [ + ['type', '=', 'sale'] + ] + }, + loaded: function (self, pricelists) { + self.db.add_pricelists(pricelists); + } + }, + { + model: 'product.pricelist.version', + fields: ['name', 'pricelist_id', 'date_start', 'date_end', 'items'], + domain: null, + loaded: function (self, versions) { + self.db.add_pricelist_versions(versions); + } + }, + { + model: 'product.pricelist.item', + fields: ['name', 'base', 'base_pricelist_id', 'categ_id', 'min_quantity', + 'price_discount', 'price_max_margin', 'price_min_margin', 'price_round', 'price_surcharge', + 'price_version_id', 'product_id', 'product_tmpl_id', 'sequence' + ], + domain: null, + loaded: function (self, items) { + self.db.add_pricelist_items(items); + } + }, + { + model: 'product.price.type', + fields: ['name', 'field', 'currency_id'], + domain: null, + loaded: function (self, price_types) { + // we need to add price type field to product.product model if not the case + var product_model = posmodel.find_model('product.product'); + for(var i = 0, len = price_types.length; i < len; i++) { + var p_type = price_types[i].field; + if (_.size(product_model) == 1) { + var product_index = parseInt(Object.keys(product_model)[0]); + if(posmodel.models[product_index].fields.indexOf(p_type) === -1) { + posmodel.models[product_index].fields.push(p_type); + } + } + } + self.db.add_price_types(price_types); + } + } + ); + } + + var res_partner_model = pos_model.find_model('res.partner'); + if (_.size(res_partner_model) == 1) { + var res_partner_index = parseInt(Object.keys(res_partner_model)[0]); + pos_model.models[res_partner_index].fields.push('property_account_position', 'property_product_pricelist'); + } + + } + +} diff --git a/pos_pricelist/static/src/js/screens.js b/pos_pricelist/static/src/js/screens.js new file mode 100644 index 00000000..9949ce15 --- /dev/null +++ b/pos_pricelist/static/src/js/screens.js @@ -0,0 +1,32 @@ +/****************************************************************************** +* Point Of Sale - Pricelist for POS Odoo +* Copyright (C) 2014 Taktik (http://www.taktik.be) +* @author Adil Houmadi +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see . +* +******************************************************************************/ +function pos_pricelist_screens(instance, module) { + + module.ClientListScreenWidget = module.ClientListScreenWidget.extend({ + save_changes: function () { + this._super(); + if (this.has_client_changed()) { + var currentOrder = this.pos.get('selectedOrder'); + var orderLines = currentOrder.get('orderLines').models; + var partner = currentOrder.get_client(); + this.pos.pricelist_engine.update_products_ui(partner); + this.pos.pricelist_engine.update_ticket(partner, orderLines); + } + } + }); +} diff --git a/pos_pricelist/static/src/js/tests.js b/pos_pricelist/static/src/js/tests.js new file mode 100644 index 00000000..ed7b232c --- /dev/null +++ b/pos_pricelist/static/src/js/tests.js @@ -0,0 +1,164 @@ +/****************************************************************************** + * Point Of Sale - Pricelist for POS Odoo + * Copyright (C) 2014 Taktik (http://www.taktik.be) + * @author Adil Houmadi + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + ******************************************************************************/ +(function () { + 'use strict'; + + openerp.Tour.register({ + id: 'pos_pricelist_order', + name: 'Complete a order trough the Front-End using POS Pricelist', + path: '/web#model=pos.session.opening&action=point_of_sale.action_pos_session_opening', + mode: 'test', + steps: [ + { + wait: 2000, + title: 'Wait for screen to be ready' + }, + { + wait: 2000, + title: 'Load the Session', + waitNot: '.oe_loading:visible', + element: 'span:contains("Resume Session"),span:contains("Start Session")' + }, + { + title: 'Loading Screen', + waitFor: '.loader' + }, + { + wait: 2000, + title: 'The Point of Sale', + waitFor: '.pos' + }, + { + title: "We will buy some Products!, let's add (POS Product 1)", + element: '.product-list .product-name:contains("POS Product 1")' + }, + { + wait: 5000, + title: 'The order total has been updated to the correct value : 100€', + waitFor: '.order .total .value:contains("100.00 €")' + }, + { + wait: 5000, + title: 'We will add one more unit!', + element: '.product-list .product-name:contains("POS Product 1")' + }, + { + wait: 4000, + title: 'We will add another unit', + element: '.product-list .product-name:contains("POS Product 1")' + }, + { + wait: 4000, + title: 'The order total should be updated : 270€ which means 90€/Unit (Rule 10% Discount from 3 Units)', + waitFor: '.order .total .value:contains("270.00 €")' + }, + { + wait: 8000, + title: 'We will add another product', + element: '.product-list .product-name:contains("POS Product 2")' + }, + { + wait: 4000, + title: 'We will add another unit for this product (POS Product 2)', + element: '.product-list .product-name:contains("POS Product 2")' + }, + { + wait: 4000, + title: "Let's verify the total that we should pay, it's should be equal to : 450€, which means that
" + + "10% Discount if offered if we buy 2 units of (POS Product 2), Rule based on standard price", + waitFor: '.order .total .value:contains("450.00 €")' + }, + { + wait: 10000, + title: "Now, we will add (POS Product 3), for this product if we buy more then 2 units
" + + "20% Discount is given by supplier to our customers", + element: '.product-list .product-name:contains("POS Product 3")' + }, + { + wait: 10000, + title: 'We will add another unit for this product (POS Product 3)', + element: '.product-list .product-name:contains("POS Product 3")' + }, + { + wait: 5000, + title: "Let's check the total (610€)", + waitFor: '.order .total .value:contains("610.00 €")' + }, + { + wait: 5000, + title: "Now, we will add (POS Product 4), this product belong to (Comptuer) category in which " + + "we apply 5% if customer buy more then 2 products", + element: '.product-list .product-name:contains("POS Product 4")' + }, + { + wait: 10000, + title: 'We will add another unit for this product (POS Product 4)', + element: '.product-list .product-name:contains("POS Product 4")' + }, + { + wait: 5000, + title: "Let's check the total again (800€)", + waitFor: '.order .total .value:contains("800.00 €")' + }, + { + wait: 5000, + title: "Let's pay the order", + element: ".paypad-button:contains('Bank')" + }, + { + wait: 1000, + title: "Let's accept the payment", + onload: function () { + window._print = window.print; + window.print = function () { + console.log('Print!') + }; + }, + element: ".button .iconlabel:contains('Validate'):visible" + }, + { + wait: 1000, + title: "Let's finish the order", + element: ".button:not(.disabled) .iconlabel:contains('Next'):visible" + }, + { + wait: 1000, + onload: function () { + window.print = window._print; + window._print = undefined; + }, + title: "Let's wait for the order posting", + waitFor: ".oe_status.js_synch .js_connected:visible" + }, + { + wait: 1000, + title: "Let's close the Point of Sale", + element: ".header-button:contains('Close')" + }, + { + title: "Let's confirm", + element: ".header-button.confirm:contains('Confirm')" + }, + { + title: "Wait for the backend to ready itself", + element: 'span:contains("Resume Session"),span:contains("Start Session")' + } + ] + }); + +})(); diff --git a/pos_pricelist/static/src/js/widgets.js b/pos_pricelist/static/src/js/widgets.js new file mode 100644 index 00000000..202575a6 --- /dev/null +++ b/pos_pricelist/static/src/js/widgets.js @@ -0,0 +1,41 @@ +/****************************************************************************** + * Point Of Sale - Pricelist for POS Odoo + * Copyright (C) 2014 Taktik (http://www.taktik.be) + * @author Adil Houmadi + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + ******************************************************************************/ +function pos_pricelist_widgets(instance, module) { + + module.OrderWidget = module.OrderWidget.extend({ + set_value: function (val) { + this._super(val); + var order = this.pos.get('selectedOrder'); + if (this.editable && order.getSelectedLine()) { + var mode = this.numpad_state.get('mode'); + if (mode === 'price') { + order.getSelectedLine().set_manuel_price(true); + } + } + } + }); + + module.OrderButtonWidget = module.OrderButtonWidget.extend({ + selectOrder: function (event) { + this._super(event); + var partner = this.order.get_client() ? this.order.get_client() : false; + this.pos.pricelist_engine.update_products_ui(partner); + } + }) +} + diff --git a/pos_pricelist/test/test.py b/pos_pricelist/test/test.py new file mode 100644 index 00000000..1309be7d --- /dev/null +++ b/pos_pricelist/test/test.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +############################################################################## +# Point Of Sale - Pricelist for POS Odoo +# Copyright (C) 2014 Taktik (http://www.taktik.be) +# @author Adil Houmadi +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## +import openerp.tests + + +@openerp.tests.common.at_install(False) +@openerp.tests.common.post_install(True) +class TestPOS(openerp.tests.HttpCase): + def test_01_pos(self): + self.phantom_js("/", "openerp.Tour.run('pos_pricelist_order', 'test')", + "openerp.Tour.tours.pos_pricelist_order", + login="admin") diff --git a/pos_pricelist/views/pos_pricelist_template.xml b/pos_pricelist/views/pos_pricelist_template.xml new file mode 100644 index 00000000..e954f1d8 --- /dev/null +++ b/pos_pricelist/views/pos_pricelist_template.xml @@ -0,0 +1,29 @@ + + + + + + + +