diff --git a/pos_product_available/models/pos_config.py b/pos_product_available/models/pos_config.py index 28eb00c93f..7aee9b212b 100644 --- a/pos_product_available/models/pos_config.py +++ b/pos_product_available/models/pos_config.py @@ -7,6 +7,6 @@ class PosConfig(models.Model): available_product = fields.Boolean() available_product_ids = fields.Many2many( - comodel_name='product.template', - string='Available Products', + comodel_name="product.template", + string="Available Products", ) \ No newline at end of file diff --git a/pos_product_available/static/src/js/models.js b/pos_product_available/static/src/js/models.js index efb8a3da32..0e46ae4b0b 100644 --- a/pos_product_available/static/src/js/models.js +++ b/pos_product_available/static/src/js/models.js @@ -1,3608 +1,3610 @@ odoo.define('point_of_sale.models', function (require) { -"use strict"; - -const { Context } = owl; -var BarcodeParser = require('barcodes.BarcodeParser'); -var BarcodeReader = require('point_of_sale.BarcodeReader'); -var PosDB = require('point_of_sale.DB'); -var devices = require('point_of_sale.devices'); -var concurrency = require('web.concurrency'); -var config = require('web.config'); -var core = require('web.core'); -var field_utils = require('web.field_utils'); -var time = require('web.time'); -var utils = require('web.utils'); - -var QWeb = core.qweb; -var _t = core._t; -var Mutex = concurrency.Mutex; -var round_di = utils.round_decimals; -var round_pr = utils.round_precision; - -var exports = {}; - -// The PosModel contains the Point Of Sale's representation of the backend. -// Since the PoS must work in standalone ( Without connection to the server ) -// it must contains a representation of the server's PoS backend. -// (taxes, product list, configuration options, etc.) this representation -// is fetched and stored by the PosModel at the initialisation. -// this is done asynchronously, a ready deferred alows the GUI to wait interactively -// for the loading to be completed -// There is a single instance of the PosModel for each Front-End instance, it is usually called -// 'pos' and is available to all widgets extending PosWidget. - -exports.PosModel = Backbone.Model.extend({ - initialize: function(attributes) { - Backbone.Model.prototype.initialize.call(this, attributes); - var self = this; - this.flush_mutex = new Mutex(); // used to make sure the orders are sent to the server once at time - - this.env = this.get('env'); - this.rpc = this.get('rpc'); - this.session = this.get('session'); - this.do_action = this.get('do_action'); - this.setLoadingMessage = this.get('setLoadingMessage'); - this.setLoadingProgress = this.get('setLoadingProgress'); - this.showLoadingSkip = this.get('showLoadingSkip'); - - this.proxy = new devices.ProxyDevice(this); // used to communicate to the hardware devices via a local proxy - this.barcode_reader = new BarcodeReader({'pos': this, proxy:this.proxy}); - - this.proxy_queue = new devices.JobQueue(); // used to prevent parallels communications to the proxy - this.db = new PosDB(); // a local database used to search trough products and categories & store pending orders - this.debug = config.isDebug(); //debug mode - - // Business data; loaded from the server at launch - this.company_logo = null; - this.company_logo_base64 = ''; - this.currency = null; - this.company = null; - this.user = null; - this.users = []; - this.employee = {name: null, id: null, barcode: null, user_id:null, pin:null}; - this.employees = []; - this.partners = []; - this.taxes = []; - this.pos_session = null; - this.config = null; - this.units = []; - this.units_by_id = {}; - this.uom_unit_id = null; - this.default_pricelist = null; - this.order_sequence = 1; - window.posmodel = this; - - // Object mapping the order's name (which contains the uid) to it's server_id after - // validation (order paid then sent to the backend). - this.validated_orders_name_server_id_map = {}; - - // 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': { status:'connected', pending:0 }, - 'orders': new OrderCollection(), - 'selectedOrder': null, - 'selectedClient': null, - 'cashier': null, - 'selectedCategoryId': null, - }); - - this.get('orders').on('remove', function(order,_unused_,options){ - self.on_removed_order(order,options.index,options.reason); - }); - - // Forward the 'client' attribute on the selected order to 'selectedClient' - function update_client() { - var order = self.get_order(); - this.set('selectedClient', order ? order.get_client() : null ); - } - this.get('orders').on('add remove change', update_client, this); - this.on('change:selectedOrder', update_client, this); - - // We fetch the backend data on the server asynchronously. this is done only when the pos user interface is launched, - // Any change on this data made on the server is thus not reflected on the point of sale until it is relaunched. - // when all the data has loaded, we compute some stuff, and declare the Pos ready to be used. - this.ready = this.load_server_data().then(function(){ - return self.after_load_server_data(); - }); - }, - after_load_server_data: function(){ - this.load_orders(); - this.set_start_order(); - if(this.config.use_proxy){ - if (this.config.iface_customer_facing_display) { - this.on('change:selectedOrder', this.send_current_order_to_customer_facing_display, this); - } - - return this.connect_to_proxy(); - } - return Promise.resolve(); - }, - // releases ressources holds by the model at the end of life of the posmodel - destroy: function(){ - // FIXME, should wait for flushing, return a deferred to indicate successfull destruction - // this.flush(); - this.proxy.disconnect(); - this.barcode_reader.disconnect_from_proxy(); - }, - - connect_to_proxy: function () { - var self = this; - return new Promise(function (resolve, reject) { - self.barcode_reader.disconnect_from_proxy(); - self.setLoadingMessage(_t('Connecting to the IoT Box'), 0); - self.showLoadingSkip(function () { - self.proxy.stop_searching(); - }); - self.proxy.autoconnect({ - force_ip: self.config.proxy_ip || undefined, - progress: function(prog){ - self.setLoadingProgress(prog); - }, - }).then( - function () { - if (self.config.iface_scan_via_proxy) { - self.barcode_reader.connect_to_proxy(); - } - resolve(); - }, - function (statusText, url) { - // this should reject so that it can be captured when we wait for pos.ready - // in the chrome component. - // then, if it got really rejected, we can show the error. - if (statusText == 'error' && window.location.protocol == 'https:') { - reject({ - title: _t('HTTPS connection to IoT Box failed'), - body: _.str.sprintf( - _t('Make sure you are using IoT Box v18.12 or higher. Navigate to %s to accept the certificate of your IoT Box.'), - url - ), - popup: 'alert', - }); - } else { - resolve(); - } - } - ); - }); - }, - - // Server side model loaders. This is the list of the models that need to be loaded from - // the server. The models are loaded one by one by this list's order. The 'loaded' callback - // is used to store the data in the appropriate place once it has been loaded. This callback - // can return a promise that will pause the loading of the next module. - // a shared temporary dictionary is available for loaders to communicate private variables - // used during loading such as object ids, etc. - models: [ - { - label: 'version', - loaded: function (self) { - return self.session.rpc('/web/webclient/version_info',{}).then(function (version) { - self.version = version; + "use strict"; + + const { Context } = owl; + var BarcodeParser = require('barcodes.BarcodeParser'); + var BarcodeReader = require('point_of_sale.BarcodeReader'); + var PosDB = require('point_of_sale.DB'); + var devices = require('point_of_sale.devices'); + var concurrency = require('web.concurrency'); + var config = require('web.config'); + var core = require('web.core'); + var field_utils = require('web.field_utils'); + var time = require('web.time'); + var utils = require('web.utils'); + + var QWeb = core.qweb; + var _t = core._t; + var Mutex = concurrency.Mutex; + var round_di = utils.round_decimals; + var round_pr = utils.round_precision; + + var exports = {}; + + // The PosModel contains the Point Of Sale's representation of the backend. + // Since the PoS must work in standalone ( Without connection to the server ) + // it must contains a representation of the server's PoS backend. + // (taxes, product list, configuration options, etc.) this representation + // is fetched and stored by the PosModel at the initialisation. + // this is done asynchronously, a ready deferred alows the GUI to wait interactively + // for the loading to be completed + // There is a single instance of the PosModel for each Front-End instance, it is usually called + // 'pos' and is available to all widgets extending PosWidget. + + exports.PosModel = Backbone.Model.extend({ + initialize: function (attributes) { + Backbone.Model.prototype.initialize.call(this, attributes); + var self = this; + this.flush_mutex = new Mutex(); // used to make sure the orders are sent to the server once at time + + this.env = this.get('env'); + this.rpc = this.get('rpc'); + this.session = this.get('session'); + this.do_action = this.get('do_action'); + this.setLoadingMessage = this.get('setLoadingMessage'); + this.setLoadingProgress = this.get('setLoadingProgress'); + this.showLoadingSkip = this.get('showLoadingSkip'); + + this.proxy = new devices.ProxyDevice(this); // used to communicate to the hardware devices via a local proxy + this.barcode_reader = new BarcodeReader({ 'pos': this, proxy: this.proxy }); + + this.proxy_queue = new devices.JobQueue(); // used to prevent parallels communications to the proxy + this.db = new PosDB(); // a local database used to search trough products and categories & store pending orders + this.debug = config.isDebug(); //debug mode + + // Business data; loaded from the server at launch + this.company_logo = null; + this.company_logo_base64 = ''; + this.currency = null; + this.company = null; + this.user = null; + this.users = []; + this.employee = { name: null, id: null, barcode: null, user_id: null, pin: null }; + this.employees = []; + this.partners = []; + this.taxes = []; + this.pos_session = null; + this.config = null; + this.units = []; + this.units_by_id = {}; + this.uom_unit_id = null; + this.default_pricelist = null; + this.order_sequence = 1; + window.posmodel = this; + + // Object mapping the order's name (which contains the uid) to it's server_id after + // validation (order paid then sent to the backend). + this.validated_orders_name_server_id_map = {}; + + // 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': { status: 'connected', pending: 0 }, + 'orders': new OrderCollection(), + 'selectedOrder': null, + 'selectedClient': null, + 'cashier': null, + 'selectedCategoryId': null, }); - }, - },{ - model: 'res.company', - fields: [ 'currency_id', 'email', 'website', 'company_registry', 'vat', 'name', 'phone', 'partner_id' , 'country_id', 'state_id', 'tax_calculation_rounding_method'], - ids: function(self){ return [self.session.user_context.allowed_company_ids[0]]; }, - loaded: function(self,companies){ self.company = companies[0]; }, - },{ - model: 'decimal.precision', - fields: ['name','digits'], - loaded: function(self,dps){ - self.dp = {}; - for (var i = 0; i < dps.length; i++) { - self.dp[dps[i].name] = dps[i].digits; - } - }, - },{ - model: 'uom.uom', - fields: [], - domain: null, - context: function(self){ return { active_test: false }; }, - loaded: function(self,units){ - self.units = units; - _.each(units, function(unit){ - self.units_by_id[unit.id] = unit; - }); - } - },{ - model: 'ir.model.data', - fields: ['res_id'], - domain: function(){ return [['name', '=', 'product_uom_unit']]; }, - loaded: function(self,unit){ - self.uom_unit_id = unit[0].res_id; - } - },{ - model: 'res.partner', - label: 'load_partners', - fields: ['name','street','city','state_id','country_id','vat','lang', - 'phone','zip','mobile','email','barcode','write_date', - 'property_account_position_id','property_product_pricelist'], - loaded: function(self,partners){ - self.partners = partners; - self.db.add_partners(partners); - }, - },{ - model: 'res.country.state', - fields: ['name', 'country_id'], - loaded: function(self,states){ - self.states = states; - }, - },{ - model: 'res.country', - fields: ['name', 'vat_label', 'code'], - loaded: function(self,countries){ - self.countries = countries; - self.company.country = null; - for (var i = 0; i < countries.length; i++) { - if (countries[i].id === self.company.country_id[0]){ - self.company.country = countries[i]; - } - } - }, - },{ - model: 'res.lang', - fields: ['name', 'code'], - loaded: function (self, langs){ - self.langs = langs; - }, - },{ - model: 'account.tax', - fields: ['name','amount', 'price_include', 'include_base_amount', 'amount_type', 'children_tax_ids'], - domain: function(self) {return [['company_id', '=', self.company && self.company.id || false]]}, - loaded: function(self, taxes){ - self.taxes = taxes; - self.taxes_by_id = {}; - _.each(taxes, function(tax){ - self.taxes_by_id[tax.id] = tax; + this.get('orders').on('remove', function (order, _unused_, options) { + self.on_removed_order(order, options.index, options.reason); }); - _.each(self.taxes_by_id, function(tax) { - tax.children_tax_ids = _.map(tax.children_tax_ids, function (child_tax_id) { - return self.taxes_by_id[child_tax_id]; - }); - }); - return new Promise(function (resolve, reject) { - var tax_ids = _.pluck(self.taxes, 'id'); - self.rpc({ - model: 'account.tax', - method: 'get_real_tax_amount', - args: [tax_ids], - }).then(function (taxes) { - _.each(taxes, function (tax) { - self.taxes_by_id[tax.id].amount = tax.amount; - }); - resolve(); - }); + + // Forward the 'client' attribute on the selected order to 'selectedClient' + function update_client() { + var order = self.get_order(); + this.set('selectedClient', order ? order.get_client() : null); + } + this.get('orders').on('add remove change', update_client, this); + this.on('change:selectedOrder', update_client, this); + + // We fetch the backend data on the server asynchronously. this is done only when the pos user interface is launched, + // Any change on this data made on the server is thus not reflected on the point of sale until it is relaunched. + // when all the data has loaded, we compute some stuff, and declare the Pos ready to be used. + this.ready = this.load_server_data().then(function () { + return self.after_load_server_data(); }); }, - },{ - model: 'pos.session', - fields: ['id', 'name', 'user_id', 'config_id', 'start_at', 'stop_at', 'sequence_number', 'payment_method_ids', 'cash_register_id', 'state'], - domain: function(self){ - var domain = [ - ['state','in',['opening_control','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.pos_session.login_number = odoo.login_number; - 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.config_id]]; }, - loaded: function(self,configs){ - self.config = configs[0]; - self.config.use_proxy = self.config.is_posbox && ( - self.config.iface_electronic_scale || - self.config.iface_print_via_proxy || - self.config.iface_scan_via_proxy || - self.config.iface_customer_facing_display); - - self.db.set_uuid(self.config.uuid); - self.set_cashier(self.get_cashier()); - // We need to do it here, since only then the local storage has the correct uuid - self.db.save('pos_session_id', self.pos_session.id); - - var orders = self.db.get_orders(); - for (var i = 0; i < orders.length; i++) { - self.pos_session.sequence_number = Math.max(self.pos_session.sequence_number, orders[i].data.sequence_number+1); - } - }, - },{ - model: 'stock.picking.type', - fields: ['use_create_lots', 'use_existing_lots'], - domain: function(self){ return [['id', '=', self.config.picking_type_id[0]]]; }, - loaded: function(self, picking_type) { - self.picking_type = picking_type[0]; - }, - },{ - model: 'res.users', - fields: ['name','company_id', 'id', 'groups_id', 'lang'], - domain: function(self){ return [['company_ids', 'in', self.config.company_id[0]],'|', ['groups_id','=', self.config.group_pos_manager_id[0]],['groups_id','=', self.config.group_pos_user_id[0]]]; }, - loaded: function(self,users){ - users.forEach(function(user) { - user.role = 'cashier'; - user.groups_id.some(function(group_id) { - if (group_id === self.config.group_pos_manager_id[0]) { - user.role = 'manager'; - return true; - } - }); - if (user.id === self.session.uid) { - self.user = user; - self.employee.name = user.name; - self.employee.role = user.role; - self.employee.user_id = [user.id, user.name]; + after_load_server_data: function () { + this.load_orders(); + this.set_start_order(); + if (this.config.use_proxy) { + if (this.config.iface_customer_facing_display) { + this.on('change:selectedOrder', this.send_current_order_to_customer_facing_display, this); } - }); - self.users = users; - self.employees = [self.employee]; - self.set_cashier(self.employee); - }, - },{ - model: 'product.pricelist', - fields: ['name', 'display_name', 'discount_policy'], - domain: function(self) { - if (self.config.use_pricelist) { - return [['id', 'in', self.config.available_pricelist_ids]]; - } else { - return [['id', '=', self.config.pricelist_id[0]]]; - } - }, - 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: 'account.bank.statement', - fields: ['id', 'balance_start'], - domain: function(self){ return [['id', '=', self.pos_session.cash_register_id[0]]]; }, - loaded: function(self, statement){ - self.bank_statement = statement[0]; - }, - },{ - 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]]; - }); + return this.connect_to_proxy(); + } + return Promise.resolve(); }, - },{ - 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; + // releases ressources holds by the model at the end of life of the posmodel + destroy: function () { + // FIXME, should wait for flushing, return a deferred to indicate successfull destruction + // this.flush(); + this.proxy.disconnect(); + this.barcode_reader.disconnect_from_proxy(); }, - },{ - model: 'res.currency', - fields: ['name','symbol','position','rounding','rate'], - ids: function(self){ return [self.config.currency_id[0], self.company.currency_id[0]]; }, - loaded: function(self, currencies){ - self.currency = currencies[0]; - if (self.currency.rounding > 0 && self.currency.rounding < 1) { - self.currency.decimals = Math.ceil(Math.log(1.0 / self.currency.rounding) / Math.log(10)); - } else { - self.currency.decimals = 0; - } - - self.company_currency = currencies[1]; - }, - },{ - model: 'pos.category', - fields: ['id', 'name', 'parent_id', 'child_id', 'write_date'], - domain: function(self) { - return self.config.limit_categories && self.config.iface_available_categ_ids.length ? [['id', 'in', self.config.iface_available_categ_ids]] : []; - }, - loaded: function(self, categories){ - self.db.add_categories(categories); - }, - },{ - model: 'product.product', - fields: ['display_name', 'lst_price', 'standard_price', 'categ_id', 'pos_categ_id', 'taxes_id', - 'barcode', 'default_code', 'to_weight', 'uom_id', 'description_sale', 'description', - 'product_tmpl_id','tracking', 'write_date', 'available_in_pos', 'attribute_line_ids', 'active'], - order: _.map(['sequence','default_code','name'], function (name) { return {name: name}; }), - domain: function(self){ - var domain = ['&', '&', ['sale_ok','=',true],['available_in_pos','=',true],'|',['company_id','=',self.config.company_id[0]],['company_id','=',false]]; - if (self.config.limit_categories && self.config.iface_available_categ_ids.length) { - domain.unshift('&'); - domain.push(['pos_categ_id', 'in', self.config.iface_available_categ_ids]); - if (self.config.available_product && self.config.available_product_ids.length) { - domain.unshift('&'); - domain.push(['product_tmpl_id', 'in', self.config.available_product_ids]); - } - } - if (self.config.iface_tipproduct){ - domain.unshift(['id', '=', self.config.tip_product_id[0]]); - domain.unshift('|'); - } - return domain; - }, - context: function(self){ return { display_default_code: false }; }, - loaded: function(self, products){ - var using_company_currency = self.config.currency_id[0] === self.company.currency_id[0]; - var conversion_rate = self.currency.rate / self.company_currency.rate; - self.db.add_products(_.map(products, function (product) { - if (!using_company_currency) { - product.lst_price = round_pr(product.lst_price * conversion_rate, self.currency.rounding); - } - product.categ = _.findWhere(self.product_categories, {'id': product.categ_id[0]}); - product.pos = self; - return new exports.Product({}, product); - })); - }, - },{ - model: 'product.attribute', - fields: ['name', 'display_type'], - condition: function (self) { return self.config.product_configurator; }, - domain: function(){ return [['create_variant', '=', 'no_variant']]; }, - loaded: function(self, product_attributes, tmp) { - tmp.product_attributes_by_id = {}; - _.map(product_attributes, function (product_attribute) { - tmp.product_attributes_by_id[product_attribute.id] = product_attribute; - }); - } - },{ - model: 'product.attribute.value', - fields: ['name', 'attribute_id', 'is_custom', 'html_color'], - condition: function (self) { return self.config.product_configurator; }, - domain: function(self, tmp){ return [['attribute_id', 'in', _.keys(tmp.product_attributes_by_id).map(parseFloat)]]; }, - loaded: function(self, pavs, tmp) { - tmp.pav_by_id = {}; - _.map(pavs, function (pav) { - tmp.pav_by_id[pav.id] = pav; - }); - } - }, { - model: 'product.template.attribute.value', - fields: ['product_attribute_value_id', 'attribute_id', 'attribute_line_id', 'price_extra'], - condition: function (self) { return self.config.product_configurator; }, - domain: function(self, tmp){ return [['attribute_id', 'in', _.keys(tmp.product_attributes_by_id).map(parseFloat)]]; }, - loaded: function(self, ptavs, tmp) { - self.attributes_by_ptal_id = {}; - _.map(ptavs, function (ptav) { - if (!self.attributes_by_ptal_id[ptav.attribute_line_id[0]]){ - self.attributes_by_ptal_id[ptav.attribute_line_id[0]] = { - id: ptav.attribute_line_id[0], - name: tmp.product_attributes_by_id[ptav.attribute_id[0]].name, - display_type: tmp.product_attributes_by_id[ptav.attribute_id[0]].display_type, - values: [], - }; - } - self.attributes_by_ptal_id[ptav.attribute_line_id[0]].values.push({ - id: ptav.product_attribute_value_id[0], - name: tmp.pav_by_id[ptav.product_attribute_value_id[0]].name, - is_custom: tmp.pav_by_id[ptav.product_attribute_value_id[0]].is_custom, - html_color: tmp.pav_by_id[ptav.product_attribute_value_id[0]].html_color, - price_extra: ptav.price_extra, - }); - }); - } - },{ - model: 'account.cash.rounding', - fields: ['name', 'rounding', 'rounding_method'], - domain: function(self){return [['id', '=', self.config.rounding_method[0]]]; }, - loaded: function(self, cash_rounding) { - self.cash_rounding = cash_rounding; - } - },{ - model: 'pos.payment.method', - fields: ['name', 'is_cash_count', 'use_payment_terminal'], - domain: function(self){return ['|',['active', '=', false], ['active', '=', true]]; }, - loaded: function(self, payment_methods) { - self.payment_methods = payment_methods.sort(function(a,b){ - // prefer cash payment_method to be first in the list - if (a.is_cash_count && !b.is_cash_count) { - return -1; - } else if (!a.is_cash_count && b.is_cash_count) { - return 1; - } else { - return a.id - b.id; - } - }); - self.payment_methods_by_id = {}; - _.each(self.payment_methods, function(payment_method) { - self.payment_methods_by_id[payment_method.id] = payment_method; - var PaymentInterface = self.electronic_payment_interfaces[payment_method.use_payment_terminal]; - if (PaymentInterface) { - payment_method.payment_terminal = new PaymentInterface(self, payment_method); - } - }); - } - },{ - model: 'account.fiscal.position', - fields: [], - domain: function(self){ return [['id','in',self.config.fiscal_position_ids]]; }, - loaded: function(self, fiscal_positions){ - self.fiscal_positions = fiscal_positions; - } - }, { - model: 'account.fiscal.position.tax', - fields: [], - domain: function(self){ - var fiscal_position_tax_ids = []; - - self.fiscal_positions.forEach(function (fiscal_position) { - fiscal_position.tax_ids.forEach(function (tax_id) { - fiscal_position_tax_ids.push(tax_id); + connect_to_proxy: function () { + var self = this; + return new Promise(function (resolve, reject) { + self.barcode_reader.disconnect_from_proxy(); + self.setLoadingMessage(_t('Connecting to the IoT Box'), 0); + self.showLoadingSkip(function () { + self.proxy.stop_searching(); }); + self.proxy.autoconnect({ + force_ip: self.config.proxy_ip || undefined, + progress: function (prog) { + self.setLoadingProgress(prog); + }, + }).then( + function () { + if (self.config.iface_scan_via_proxy) { + self.barcode_reader.connect_to_proxy(); + } + resolve(); + }, + function (statusText, url) { + // this should reject so that it can be captured when we wait for pos.ready + // in the chrome component. + // then, if it got really rejected, we can show the error. + if (statusText == 'error' && window.location.protocol == 'https:') { + reject({ + title: _t('HTTPS connection to IoT Box failed'), + body: _.str.sprintf( + _t('Make sure you are using IoT Box v18.12 or higher. Navigate to %s to accept the certificate of your IoT Box.'), + url + ), + popup: 'alert', + }); + } else { + resolve(); + } + } + ); }); - - return [['id','in',fiscal_position_tax_ids]]; }, - loaded: function(self, fiscal_position_taxes){ - self.fiscal_position_taxes = fiscal_position_taxes; - self.fiscal_positions.forEach(function (fiscal_position) { - fiscal_position.fiscal_position_taxes_by_id = {}; - fiscal_position.tax_ids.forEach(function (tax_id) { - var fiscal_position_tax = _.find(fiscal_position_taxes, function (fiscal_position_tax) { - return fiscal_position_tax.id === tax_id; + + // Server side model loaders. This is the list of the models that need to be loaded from + // the server. The models are loaded one by one by this list's order. The 'loaded' callback + // is used to store the data in the appropriate place once it has been loaded. This callback + // can return a promise that will pause the loading of the next module. + // a shared temporary dictionary is available for loaders to communicate private variables + // used during loading such as object ids, etc. + models: [ + { + label: 'version', + loaded: function (self) { + return self.session.rpc('/web/webclient/version_info', {}).then(function (version) { + self.version = version; }); + }, - fiscal_position.fiscal_position_taxes_by_id[fiscal_position_tax.id] = fiscal_position_tax; - }); - }); - } - }, { - label: 'fonts', - loaded: function(){ - return new Promise(function (resolve, reject) { - // Waiting for fonts to be loaded to prevent receipt printing - // from printing empty receipt while loading Inconsolata - // ( The font used for the receipt ) - waitForWebfonts(['Lato','Inconsolata'], function () { - resolve(); - }); - // The JS used to detect font loading is not 100% robust, so - // do not wait more than 5sec - setTimeout(resolve, 5000); - }); - }, - },{ - label: 'pictures', - loaded: function (self) { - self.company_logo = new Image(); - return new Promise(function (resolve, reject) { - self.company_logo.onload = function () { - var img = self.company_logo; - var ratio = 1; - var targetwidth = 300; - var maxheight = 150; - if( img.width !== targetwidth ){ - ratio = targetwidth / img.width; + }, { + model: 'res.company', + fields: ['currency_id', 'email', 'website', 'company_registry', 'vat', 'name', 'phone', 'partner_id', 'country_id', 'state_id', 'tax_calculation_rounding_method'], + ids: function (self) { return [self.session.user_context.allowed_company_ids[0]]; }, + loaded: function (self, companies) { self.company = companies[0]; }, + }, { + model: 'decimal.precision', + fields: ['name', 'digits'], + loaded: function (self, dps) { + self.dp = {}; + for (var i = 0; i < dps.length; i++) { + self.dp[dps[i].name] = dps[i].digits; } - if( img.height * ratio > maxheight ){ - ratio = maxheight / img.height; + }, + }, { + model: 'uom.uom', + fields: [], + domain: null, + context: function (self) { return { active_test: false }; }, + loaded: function (self, units) { + self.units = units; + _.each(units, function (unit) { + self.units_by_id[unit.id] = unit; + }); + } + }, { + model: 'ir.model.data', + fields: ['res_id'], + domain: function () { return [['name', '=', 'product_uom_unit']]; }, + loaded: function (self, unit) { + self.uom_unit_id = unit[0].res_id; + } + }, { + model: 'res.partner', + label: 'load_partners', + fields: ['name', 'street', 'city', 'state_id', 'country_id', 'vat', 'lang', + 'phone', 'zip', 'mobile', 'email', 'barcode', 'write_date', + 'property_account_position_id', 'property_product_pricelist'], + loaded: function (self, partners) { + self.partners = partners; + self.db.add_partners(partners); + }, + }, { + model: 'res.country.state', + fields: ['name', 'country_id'], + loaded: function (self, states) { + self.states = states; + }, + }, { + model: 'res.country', + fields: ['name', 'vat_label', 'code'], + loaded: function (self, countries) { + self.countries = countries; + self.company.country = null; + for (var i = 0; i < countries.length; i++) { + if (countries[i].id === self.company.country_id[0]) { + self.company.country = countries[i]; + } } - var width = Math.floor(img.width * ratio); - var height = Math.floor(img.height * ratio); - var c = document.createElement('canvas'); - c.width = width; - c.height = height; - var ctx = c.getContext('2d'); - ctx.drawImage(self.company_logo,0,0, width, height); - - self.company_logo_base64 = c.toDataURL(); - resolve(); - }; - self.company_logo.onerror = function () { - reject(); - }; - self.company_logo.crossOrigin = "anonymous"; - self.company_logo.src = `/web/image?model=res.company&id=${self.company.id}&field=logo`; - }); - }, - }, { - label: 'barcodes', - loaded: function(self) { - var barcode_parser = new BarcodeParser({'nomenclature_id': self.config.barcode_nomenclature_id}); - self.barcode_reader.set_barcode_parser(barcode_parser); - return barcode_parser.is_loaded(); - }, - }, - ], - - // loads all the needed data on the sever. returns a promise indicating when all the data has loaded. - load_server_data: function(){ - var self = this; - var progress = 0; - var progress_step = 1.0 / self.models.length; - var tmp = {}; // this is used to share a temporary state between models loaders - - var loaded = new Promise(function (resolve, reject) { - function load_model(index) { - if (index >= self.models.length) { - resolve(); - } else { - var model = self.models[index]; - self.setLoadingMessage(_t('Loading')+' '+(model.label || model.model || ''), progress); + }, + }, { + model: 'res.lang', + fields: ['name', 'code'], + loaded: function (self, langs) { + self.langs = langs; + }, + }, { + model: 'account.tax', + fields: ['name', 'amount', 'price_include', 'include_base_amount', 'amount_type', 'children_tax_ids'], + domain: function (self) { return [['company_id', '=', self.company && self.company.id || false]] }, + loaded: function (self, taxes) { + self.taxes = taxes; + self.taxes_by_id = {}; + _.each(taxes, function (tax) { + self.taxes_by_id[tax.id] = tax; + }); + _.each(self.taxes_by_id, function (tax) { + tax.children_tax_ids = _.map(tax.children_tax_ids, function (child_tax_id) { + return self.taxes_by_id[child_tax_id]; + }); + }); + return new Promise(function (resolve, reject) { + var tax_ids = _.pluck(self.taxes, 'id'); + self.rpc({ + model: 'account.tax', + method: 'get_real_tax_amount', + args: [tax_ids], + }).then(function (taxes) { + _.each(taxes, function (tax) { + self.taxes_by_id[tax.id].amount = tax.amount; + }); + resolve(); + }); + }); + }, + }, { + model: 'pos.session', + fields: ['id', 'name', 'user_id', 'config_id', 'start_at', 'stop_at', 'sequence_number', 'payment_method_ids', 'cash_register_id', 'state'], + domain: function (self) { + var domain = [ + ['state', 'in', ['opening_control', '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.pos_session.login_number = odoo.login_number; + 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.config_id]]; }, + loaded: function (self, configs) { + self.config = configs[0]; + self.config.use_proxy = self.config.is_posbox && ( + self.config.iface_electronic_scale || + self.config.iface_print_via_proxy || + self.config.iface_scan_via_proxy || + self.config.iface_customer_facing_display); + + self.db.set_uuid(self.config.uuid); + self.set_cashier(self.get_cashier()); + // We need to do it here, since only then the local storage has the correct uuid + self.db.save('pos_session_id', self.pos_session.id); + + var orders = self.db.get_orders(); + for (var i = 0; i < orders.length; i++) { + self.pos_session.sequence_number = Math.max(self.pos_session.sequence_number, orders[i].data.sequence_number + 1); + } + }, + }, { + model: 'stock.picking.type', + fields: ['use_create_lots', 'use_existing_lots'], + domain: function (self) { return [['id', '=', self.config.picking_type_id[0]]]; }, + loaded: function (self, picking_type) { + self.picking_type = picking_type[0]; + }, + }, { + model: 'res.users', + fields: ['name', 'company_id', 'id', 'groups_id', 'lang'], + domain: function (self) { return [['company_ids', 'in', self.config.company_id[0]], '|', ['groups_id', '=', self.config.group_pos_manager_id[0]], ['groups_id', '=', self.config.group_pos_user_id[0]]]; }, + loaded: function (self, users) { + users.forEach(function (user) { + user.role = 'cashier'; + user.groups_id.some(function (group_id) { + if (group_id === self.config.group_pos_manager_id[0]) { + user.role = 'manager'; + return true; + } + }); + if (user.id === self.session.uid) { + self.user = user; + self.employee.name = user.name; + self.employee.role = user.role; + self.employee.user_id = [user.id, user.name]; + } + }); + self.users = users; + self.employees = [self.employee]; + self.set_cashier(self.employee); + }, + }, { + model: 'product.pricelist', + fields: ['name', 'display_name', 'discount_policy'], + domain: function (self) { + if (self.config.use_pricelist) { + return [['id', 'in', self.config.available_pricelist_ids]]; + } else { + return [['id', '=', self.config.pricelist_id[0]]]; + } + }, + 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: 'account.bank.statement', + fields: ['id', 'balance_start'], + domain: function (self) { return [['id', '=', self.pos_session.cash_register_id[0]]]; }, + loaded: function (self, statement) { + self.bank_statement = statement[0]; + }, + }, { + 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; + }); - var cond = typeof model.condition === 'function' ? model.condition(self,tmp) : true; - if (!cond) { - load_model(index+1); - return; + _.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', 'rate'], + ids: function (self) { return [self.config.currency_id[0], self.company.currency_id[0]]; }, + loaded: function (self, currencies) { + self.currency = currencies[0]; + if (self.currency.rounding > 0 && self.currency.rounding < 1) { + self.currency.decimals = Math.ceil(Math.log(1.0 / self.currency.rounding) / Math.log(10)); + } else { + self.currency.decimals = 0; + } + + self.company_currency = currencies[1]; + }, + }, { + model: 'pos.category', + fields: ['id', 'name', 'parent_id', 'child_id', 'write_date'], + domain: function (self) { + return self.config.limit_categories && self.config.iface_available_categ_ids.length ? [['id', 'in', self.config.iface_available_categ_ids]] : []; + }, + loaded: function (self, categories) { + self.db.add_categories(categories); + }, + }, { + model: 'product.product', + fields: ['display_name', 'lst_price', 'standard_price', 'categ_id', 'pos_categ_id', 'taxes_id', + 'barcode', 'default_code', 'to_weight', 'uom_id', 'description_sale', 'description', + 'product_tmpl_id', 'tracking', 'write_date', 'available_in_pos', 'attribute_line_ids', 'active'], + order: _.map(['sequence', 'default_code', 'name'], function (name) { return { name: name }; }), + domain: function (self) { + var domain = ['&', '&', ['sale_ok', '=', true], ['available_in_pos', '=', true], '|', ['company_id', '=', self.config.company_id[0]], ['company_id', '=', false]]; + if (self.config.limit_categories && self.config.iface_available_categ_ids.length) { + domain.unshift('&'); + domain.push(['pos_categ_id', 'in', self.config.iface_available_categ_ids]); + if (self.config.available_product && self.config.available_product_ids.length) { + domain.unshift('&'); + domain.push(['product_tmpl_id', 'in', self.config.available_product_ids]); + } + } + if (self.config.iface_tipproduct) { + domain.unshift(['id', '=', self.config.tip_product_id[0]]); + domain.unshift('|'); } + return domain; + }, + context: function (self) { return { display_default_code: false }; }, + loaded: function (self, products) { + var using_company_currency = self.config.currency_id[0] === self.company.currency_id[0]; + var conversion_rate = self.currency.rate / self.company_currency.rate; + self.db.add_products(_.map(products, function (product) { + if (!using_company_currency) { + product.lst_price = round_pr(product.lst_price * conversion_rate, self.currency.rounding); + } + product.categ = _.findWhere(self.product_categories, { 'id': product.categ_id[0] }); + product.pos = self; + return new exports.Product({}, product); + })); + }, + }, { + model: 'product.attribute', + fields: ['name', 'display_type'], + condition: function (self) { return self.config.product_configurator; }, + domain: function () { return [['create_variant', '=', 'no_variant']]; }, + loaded: function (self, product_attributes, tmp) { + tmp.product_attributes_by_id = {}; + _.map(product_attributes, function (product_attribute) { + tmp.product_attributes_by_id[product_attribute.id] = product_attribute; + }); + } + }, { + model: 'product.attribute.value', + fields: ['name', 'attribute_id', 'is_custom', 'html_color'], + condition: function (self) { return self.config.product_configurator; }, + domain: function (self, tmp) { return [['attribute_id', 'in', _.keys(tmp.product_attributes_by_id).map(parseFloat)]]; }, + loaded: function (self, pavs, tmp) { + tmp.pav_by_id = {}; + _.map(pavs, function (pav) { + tmp.pav_by_id[pav.id] = pav; + }); + } + }, { + model: 'product.template.attribute.value', + fields: ['product_attribute_value_id', 'attribute_id', 'attribute_line_id', 'price_extra'], + condition: function (self) { return self.config.product_configurator; }, + domain: function (self, tmp) { return [['attribute_id', 'in', _.keys(tmp.product_attributes_by_id).map(parseFloat)]]; }, + loaded: function (self, ptavs, tmp) { + self.attributes_by_ptal_id = {}; + _.map(ptavs, function (ptav) { + if (!self.attributes_by_ptal_id[ptav.attribute_line_id[0]]) { + self.attributes_by_ptal_id[ptav.attribute_line_id[0]] = { + id: ptav.attribute_line_id[0], + name: tmp.product_attributes_by_id[ptav.attribute_id[0]].name, + display_type: tmp.product_attributes_by_id[ptav.attribute_id[0]].display_type, + values: [], + }; + } + self.attributes_by_ptal_id[ptav.attribute_line_id[0]].values.push({ + id: ptav.product_attribute_value_id[0], + name: tmp.pav_by_id[ptav.product_attribute_value_id[0]].name, + is_custom: tmp.pav_by_id[ptav.product_attribute_value_id[0]].is_custom, + html_color: tmp.pav_by_id[ptav.product_attribute_value_id[0]].html_color, + price_extra: ptav.price_extra, + }); + }); + } + }, { + model: 'account.cash.rounding', + fields: ['name', 'rounding', 'rounding_method'], + domain: function (self) { return [['id', '=', self.config.rounding_method[0]]]; }, + loaded: function (self, cash_rounding) { + self.cash_rounding = cash_rounding; + } + }, { + model: 'pos.payment.method', + fields: ['name', 'is_cash_count', 'use_payment_terminal'], + domain: function (self) { return ['|', ['active', '=', false], ['active', '=', true]]; }, + loaded: function (self, payment_methods) { + self.payment_methods = payment_methods.sort(function (a, b) { + // prefer cash payment_method to be first in the list + if (a.is_cash_count && !b.is_cash_count) { + return -1; + } else if (!a.is_cash_count && b.is_cash_count) { + return 1; + } else { + return a.id - b.id; + } + }); + self.payment_methods_by_id = {}; + _.each(self.payment_methods, function (payment_method) { + self.payment_methods_by_id[payment_method.id] = payment_method; + + var PaymentInterface = self.electronic_payment_interfaces[payment_method.use_payment_terminal]; + if (PaymentInterface) { + payment_method.payment_terminal = new PaymentInterface(self, payment_method); + } + }); + } + }, { + model: 'account.fiscal.position', + fields: [], + domain: function (self) { return [['id', 'in', self.config.fiscal_position_ids]]; }, + loaded: function (self, fiscal_positions) { + self.fiscal_positions = fiscal_positions; + } + }, { + model: 'account.fiscal.position.tax', + fields: [], + domain: function (self) { + var fiscal_position_tax_ids = []; + + self.fiscal_positions.forEach(function (fiscal_position) { + fiscal_position.tax_ids.forEach(function (tax_id) { + fiscal_position_tax_ids.push(tax_id); + }); + }); - var fields = typeof model.fields === 'function' ? model.fields(self,tmp) : model.fields; - var domain = typeof model.domain === 'function' ? model.domain(self,tmp) : model.domain; - var context = typeof model.context === 'function' ? model.context(self,tmp) : model.context || {}; - var ids = typeof model.ids === 'function' ? model.ids(self,tmp) : model.ids; - var order = typeof model.order === 'function' ? model.order(self,tmp): model.order; - progress += progress_step; - - if( model.model ){ - var params = { - model: model.model, - context: _.extend(context, self.session.user_context || {}), + return [['id', 'in', fiscal_position_tax_ids]]; + }, + loaded: function (self, fiscal_position_taxes) { + self.fiscal_position_taxes = fiscal_position_taxes; + self.fiscal_positions.forEach(function (fiscal_position) { + fiscal_position.fiscal_position_taxes_by_id = {}; + fiscal_position.tax_ids.forEach(function (tax_id) { + var fiscal_position_tax = _.find(fiscal_position_taxes, function (fiscal_position_tax) { + return fiscal_position_tax.id === tax_id; + }); + + fiscal_position.fiscal_position_taxes_by_id[fiscal_position_tax.id] = fiscal_position_tax; + }); + }); + } + }, { + label: 'fonts', + loaded: function () { + return new Promise(function (resolve, reject) { + // Waiting for fonts to be loaded to prevent receipt printing + // from printing empty receipt while loading Inconsolata + // ( The font used for the receipt ) + waitForWebfonts(['Lato', 'Inconsolata'], function () { + resolve(); + }); + // The JS used to detect font loading is not 100% robust, so + // do not wait more than 5sec + setTimeout(resolve, 5000); + }); + }, + }, { + label: 'pictures', + loaded: function (self) { + self.company_logo = new Image(); + return new Promise(function (resolve, reject) { + self.company_logo.onload = function () { + var img = self.company_logo; + var ratio = 1; + var targetwidth = 300; + var maxheight = 150; + if (img.width !== targetwidth) { + ratio = targetwidth / img.width; + } + if (img.height * ratio > maxheight) { + ratio = maxheight / img.height; + } + var width = Math.floor(img.width * ratio); + var height = Math.floor(img.height * ratio); + var c = document.createElement('canvas'); + c.width = width; + c.height = height; + var ctx = c.getContext('2d'); + ctx.drawImage(self.company_logo, 0, 0, width, height); + + self.company_logo_base64 = c.toDataURL(); + resolve(); }; + self.company_logo.onerror = function () { + reject(); + }; + self.company_logo.crossOrigin = "anonymous"; + self.company_logo.src = `/web/image?model=res.company&id=${self.company.id}&field=logo`; + }); + }, + }, { + label: 'barcodes', + loaded: function (self) { + var barcode_parser = new BarcodeParser({ 'nomenclature_id': self.config.barcode_nomenclature_id }); + self.barcode_reader.set_barcode_parser(barcode_parser); + return barcode_parser.is_loaded(); + }, + }, + ], - if (model.ids) { - params.method = 'read'; - params.args = [ids, fields]; - } else { - params.method = 'search_read'; - params.domain = domain; - params.fields = fields; - params.orderBy = order; + // loads all the needed data on the sever. returns a promise indicating when all the data has loaded. + load_server_data: function () { + var self = this; + var progress = 0; + var progress_step = 1.0 / self.models.length; + var tmp = {}; // this is used to share a temporary state between models loaders + + var loaded = new Promise(function (resolve, reject) { + function load_model(index) { + if (index >= self.models.length) { + resolve(); + } else { + var model = self.models[index]; + self.setLoadingMessage(_t('Loading') + ' ' + (model.label || model.model || ''), progress); + + var cond = typeof model.condition === 'function' ? model.condition(self, tmp) : true; + if (!cond) { + load_model(index + 1); + return; } - self.rpc(params).then(function (result) { + var fields = typeof model.fields === 'function' ? model.fields(self, tmp) : model.fields; + var domain = typeof model.domain === 'function' ? model.domain(self, tmp) : model.domain; + var context = typeof model.context === 'function' ? model.context(self, tmp) : model.context || {}; + var ids = typeof model.ids === 'function' ? model.ids(self, tmp) : model.ids; + var order = typeof model.order === 'function' ? model.order(self, tmp) : model.order; + progress += progress_step; + + if (model.model) { + var params = { + model: model.model, + context: _.extend(context, self.session.user_context || {}), + }; + + if (model.ids) { + params.method = 'read'; + params.args = [ids, fields]; + } else { + params.method = 'search_read'; + params.domain = domain; + params.fields = fields; + params.orderBy = order; + } + + self.rpc(params).then(function (result) { + try { // catching exceptions in model.loaded(...) + Promise.resolve(model.loaded(self, result, tmp)) + .then(function () { load_model(index + 1); }, + function (err) { reject(err); }); + } catch (err) { + console.error(err.message, err.stack); + reject(err); + } + }, function (err) { + reject(err); + }); + } else if (model.loaded) { try { // catching exceptions in model.loaded(...) - Promise.resolve(model.loaded(self, result, tmp)) + Promise.resolve(model.loaded(self, tmp)) .then(function () { load_model(index + 1); }, function (err) { reject(err); }); } catch (err) { - console.error(err.message, err.stack); reject(err); } - }, function (err) { - reject(err); - }); - } else if (model.loaded) { - try { // catching exceptions in model.loaded(...) - Promise.resolve(model.loaded(self, tmp)) - .then(function () { load_model(index +1); }, - function (err) { reject(err); }); - } catch (err) { - reject(err); + } else { + load_model(index + 1); } - } else { - load_model(index + 1); } } - } - try { - return load_model(0); - } catch (err) { - return Promise.reject(err); - } - }); + try { + return load_model(0); + } catch (err) { + return Promise.reject(err); + } + }); - return loaded; - }, + return loaded; + }, - prepare_new_partners_domain: function(){ - return [['write_date','>', this.db.get_partner_write_date()]]; - }, + prepare_new_partners_domain: function () { + return [['write_date', '>', this.db.get_partner_write_date()]]; + }, - // reload the list of partner, returns as a promise that resolves if there were - // updated partners, and fails if not - load_new_partners: function(){ - var self = this; - return new Promise(function (resolve, reject) { - var fields = _.find(self.models, function(model){ return model.label === 'load_partners'; }).fields; - var domain = self.prepare_new_partners_domain(); - self.rpc({ - model: 'res.partner', - method: 'search_read', - args: [domain, fields], - }, { - timeout: 3000, - shadow: true, - }) - .then(function (partners) { - if (self.db.add_partners(partners)) { // check if the partners we got were real updates - resolve(); - } else { - reject(new Error('Failed in updating partners.')); - } - }, function (type, err) { reject(); }); - }); - }, - - // this is called when an order is removed from the order collection. It ensures that there is always an existing - // order and a valid selected order - on_removed_order: function(removed_order,index,reason){ - var order_list = this.get_order_list(); - if( (reason === 'abandon' || removed_order.temporary) && order_list.length > 0){ - // when we intentionally remove an unfinished order, and there is another existing one - this.set_order(order_list[index] || order_list[order_list.length - 1], { silent: true }); - }else{ - // when the order was automatically removed after completion, - // or when we intentionally delete the only concurrent order - this.add_new_order({ silent: true }); - } - }, + // reload the list of partner, returns as a promise that resolves if there were + // updated partners, and fails if not + load_new_partners: function () { + var self = this; + return new Promise(function (resolve, reject) { + var fields = _.find(self.models, function (model) { return model.label === 'load_partners'; }).fields; + var domain = self.prepare_new_partners_domain(); + self.rpc({ + model: 'res.partner', + method: 'search_read', + args: [domain, fields], + }, { + timeout: 3000, + shadow: true, + }) + .then(function (partners) { + if (self.db.add_partners(partners)) { // check if the partners we got were real updates + resolve(); + } else { + reject(new Error('Failed in updating partners.')); + } + }, function (type, err) { reject(); }); + }); + }, - // returns the user who is currently the cashier for this point of sale - get_cashier: function(){ - // reset the cashier to the current user if session is new - if (this.db.load('pos_session_id') !== this.pos_session.id) { - this.set_cashier(this.employee); - } - return this.db.get_cashier() || this.get('cashier') || this.employee; - }, - // changes the current cashier - set_cashier: function(employee){ - this.set('cashier', employee); - this.db.set_cashier(this.get('cashier')); - }, - // creates a new empty order and sets it as the current order - add_new_order: function(options){ - var order = new exports.Order({},{pos:this}); - this.get('orders').add(order); - this.set('selectedOrder', order, options); - return order; - }, - /** - * Load the locally saved unpaid orders for this PoS Config. - * - * First load all orders belonging to the current session. - * Second load all orders belonging to the same config but from other sessions, - * Only if tho order has orderlines. - */ - load_orders: async function(){ - var jsons = this.db.get_unpaid_orders(); - await this._loadMissingProducts(jsons); - var orders = []; - - for (var i = 0; i < jsons.length; i++) { - var json = jsons[i]; - if (json.pos_session_id === this.pos_session.id) { - orders.push(new exports.Order({},{ - pos: this, - json: json, - })); + // this is called when an order is removed from the order collection. It ensures that there is always an existing + // order and a valid selected order + on_removed_order: function (removed_order, index, reason) { + var order_list = this.get_order_list(); + if ((reason === 'abandon' || removed_order.temporary) && order_list.length > 0) { + // when we intentionally remove an unfinished order, and there is another existing one + this.set_order(order_list[index] || order_list[order_list.length - 1], { silent: true }); + } else { + // when the order was automatically removed after completion, + // or when we intentionally delete the only concurrent order + this.add_new_order({ silent: true }); } - } - for (var i = 0; i < jsons.length; i++) { - var json = jsons[i]; - if (json.pos_session_id !== this.pos_session.id && (json.lines.length > 0 || json.statement_ids.length > 0)) { - orders.push(new exports.Order({},{ - pos: this, - json: json, - })); - } else if (json.pos_session_id !== this.pos_session.id) { - this.db.remove_unpaid_order(jsons[i]); + }, + + // returns the user who is currently the cashier for this point of sale + get_cashier: function () { + // reset the cashier to the current user if session is new + if (this.db.load('pos_session_id') !== this.pos_session.id) { + this.set_cashier(this.employee); + } + return this.db.get_cashier() || this.get('cashier') || this.employee; + }, + // changes the current cashier + set_cashier: function (employee) { + this.set('cashier', employee); + this.db.set_cashier(this.get('cashier')); + }, + // creates a new empty order and sets it as the current order + add_new_order: function (options) { + var order = new exports.Order({}, { pos: this }); + this.get('orders').add(order); + this.set('selectedOrder', order, options); + return order; + }, + /** + * Load the locally saved unpaid orders for this PoS Config. + * + * First load all orders belonging to the current session. + * Second load all orders belonging to the same config but from other sessions, + * Only if tho order has orderlines. + */ + load_orders: async function () { + var jsons = this.db.get_unpaid_orders(); + await this._loadMissingProducts(jsons); + var orders = []; + + for (var i = 0; i < jsons.length; i++) { + var json = jsons[i]; + if (json.pos_session_id === this.pos_session.id) { + orders.push(new exports.Order({}, { + pos: this, + json: json, + })); + } + } + for (var i = 0; i < jsons.length; i++) { + var json = jsons[i]; + if (json.pos_session_id !== this.pos_session.id && (json.lines.length > 0 || json.statement_ids.length > 0)) { + orders.push(new exports.Order({}, { + pos: this, + json: json, + })); + } else if (json.pos_session_id !== this.pos_session.id) { + this.db.remove_unpaid_order(jsons[i]); + } } - } - orders = orders.sort(function(a,b){ - return a.sequence_number - b.sequence_number; - }); + orders = orders.sort(function (a, b) { + return a.sequence_number - b.sequence_number; + }); - if (orders.length) { - this.get('orders').add(orders); - } - }, - async _loadMissingProducts(orders) { - const missingProductIds = new Set([]); - for (const order of orders) { - for (const line of order.lines) { - const productId = line[2].product_id; - if (missingProductIds.has(productId)) continue; - if (!this.db.get_product_by_id(productId)) { - missingProductIds.add(productId); + if (orders.length) { + this.get('orders').add(orders); + } + }, + async _loadMissingProducts(orders) { + const missingProductIds = new Set([]); + for (const order of orders) { + for (const line of order.lines) { + const productId = line[2].product_id; + if (missingProductIds.has(productId)) continue; + if (!this.db.get_product_by_id(productId)) { + missingProductIds.add(productId); + } } } - } - const productModel = _.find(this.models, function(model){return model.model === 'product.product';}); - const fields = productModel.fields; - const products = await this.rpc({ - model: 'product.product', - method: 'read', - args: [[...missingProductIds], fields], - context: Object.assign(this.session.user_context, { display_default_code: false }), - }); - productModel.loaded(this, products); - }, - set_start_order: function(){ - var orders = this.get('orders').models; - - if (orders.length && !this.get('selectedOrder')) { - this.set('selectedOrder',orders[0]); - } else { - this.add_new_order(); - } - }, + const productModel = _.find(this.models, function (model) { return model.model === 'product.product'; }); + const fields = productModel.fields; + const products = await this.rpc({ + model: 'product.product', + method: 'read', + args: [[...missingProductIds], fields], + context: Object.assign(this.session.user_context, { display_default_code: false }), + }); + productModel.loaded(this, products); + }, + set_start_order: function () { + var orders = this.get('orders').models; - // return the current order - get_order: function(){ - return this.get('selectedOrder'); - }, + if (orders.length && !this.get('selectedOrder')) { + this.set('selectedOrder', orders[0]); + } else { + this.add_new_order(); + } + }, - get_client: function() { - var order = this.get_order(); - if (order) { - return order.get_client(); - } - return null; - }, - - // change the current order - set_order: function(order, options){ - this.set({ selectedOrder: order }, options); - }, - - // return the list of unpaid orders - get_order_list: function(){ - return this.get('orders').models; - }, - - //removes the current order - delete_current_order: function(){ - var order = this.get_order(); - if (order) { - order.destroy({'reason':'abandon'}); - } - }, + // return the current order + get_order: function () { + return this.get('selectedOrder'); + }, - _convert_product_img_to_base64: function (product, url) { - return new Promise(function (resolve, reject) { - var img = new Image(); + get_client: function () { + var order = this.get_order(); + if (order) { + return order.get_client(); + } + return null; + }, - img.onload = function () { - var canvas = document.createElement('CANVAS'); - var ctx = canvas.getContext('2d'); + // change the current order + set_order: function (order, options) { + this.set({ selectedOrder: order }, options); + }, - canvas.height = this.height; - canvas.width = this.width; - ctx.drawImage(this,0,0); + // return the list of unpaid orders + get_order_list: function () { + return this.get('orders').models; + }, - var dataURL = canvas.toDataURL('image/jpeg'); - product.image_base64 = dataURL; - canvas = null; + //removes the current order + delete_current_order: function () { + var order = this.get_order(); + if (order) { + order.destroy({ 'reason': 'abandon' }); + } + }, - resolve(); - }; - img.crossOrigin = 'use-credentials'; - img.src = url; - }); - }, - - send_current_order_to_customer_facing_display: function() { - var self = this; - this.render_html_for_customer_facing_display().then(function (rendered_html) { - self.proxy.update_customer_facing_display(rendered_html); - }); - }, + _convert_product_img_to_base64: function (product, url) { + return new Promise(function (resolve, reject) { + var img = new Image(); - /** - * @returns {Promise} - */ - render_html_for_customer_facing_display: function () { - var self = this; - var order = this.get_order(); - var rendered_html = this.config.customer_facing_display_html; - - // If we're using an external device like the IoT Box, we - // cannot get /web/image?model=product.product because the - // IoT Box is not logged in and thus doesn't have the access - // rights to access product.product. So instead we'll base64 - // encode it and embed it in the HTML. - var get_image_promises = []; - - if (order) { - order.get_orderlines().forEach(function (orderline) { - var product = orderline.product; - var image_url = `/web/image?model=product.product&field=image_128&id=${product.id}&write_date=${product.write_date}&unique=1`; - - // only download and convert image if we haven't done it before - if (! product.image_base64) { - get_image_promises.push(self._convert_product_img_to_base64(product, image_url)); - } + img.onload = function () { + var canvas = document.createElement('CANVAS'); + var ctx = canvas.getContext('2d'); + + canvas.height = this.height; + canvas.width = this.width; + ctx.drawImage(this, 0, 0); + + var dataURL = canvas.toDataURL('image/jpeg'); + product.image_base64 = dataURL; + canvas = null; + + resolve(); + }; + img.crossOrigin = 'use-credentials'; + img.src = url; }); - } + }, + + send_current_order_to_customer_facing_display: function () { + var self = this; + this.render_html_for_customer_facing_display().then(function (rendered_html) { + self.proxy.update_customer_facing_display(rendered_html); + }); + }, + + /** + * @returns {Promise} + */ + render_html_for_customer_facing_display: function () { + var self = this; + var order = this.get_order(); + var rendered_html = this.config.customer_facing_display_html; - // when all images are loaded in product.image_base64 - return Promise.all(get_image_promises).then(function () { - var rendered_order_lines = ""; - var rendered_payment_lines = ""; - var order_total_with_tax = self.format_currency(0); + // If we're using an external device like the IoT Box, we + // cannot get /web/image?model=product.product because the + // IoT Box is not logged in and thus doesn't have the access + // rights to access product.product. So instead we'll base64 + // encode it and embed it in the HTML. + var get_image_promises = []; if (order) { - rendered_order_lines = QWeb.render('CustomerFacingDisplayOrderLines', { - 'orderlines': order.get_orderlines(), - 'pos': self, - }); - rendered_payment_lines = QWeb.render('CustomerFacingDisplayPaymentLines', { - 'order': order, - 'pos': self, + order.get_orderlines().forEach(function (orderline) { + var product = orderline.product; + var image_url = `/web/image?model=product.product&field=image_128&id=${product.id}&write_date=${product.write_date}&unique=1`; + + // only download and convert image if we haven't done it before + if (!product.image_base64) { + get_image_promises.push(self._convert_product_img_to_base64(product, image_url)); + } }); - order_total_with_tax = self.format_currency(order.get_total_with_tax()); - } - - var $rendered_html = $(rendered_html); - $rendered_html.find('.pos_orderlines_list').html(rendered_order_lines); - $rendered_html.find('.pos-total').find('.pos_total-amount').html(order_total_with_tax); - var pos_change_title = $rendered_html.find('.pos-change_title').text(); - $rendered_html.find('.pos-paymentlines').html(rendered_payment_lines); - $rendered_html.find('.pos-change_title').text(pos_change_title); - - // prop only uses the first element in a set of elements, - // and there's no guarantee that - // customer_facing_display_html is wrapped in a single - // root element. - rendered_html = _.reduce($rendered_html, function (memory, current_element) { - return memory + $(current_element).prop('outerHTML'); - }, ""); // initial memory of "" - - rendered_html = QWeb.render('CustomerFacingDisplayHead', { - origin: window.location.origin - }) + rendered_html; - return rendered_html; - }); - }, - - // saves the order locally and try to send it to the backend. - // it returns a promise that succeeds after having tried to send the order and all the other pending orders. - push_orders: function (order, opts) { - opts = opts || {}; - var self = this; - - if (order) { - this.db.add_order(order.export_as_JSON()); - } + } - return new Promise(function (resolve, reject) { - self.flush_mutex.exec(function () { - var flushed = self._flush_orders(self.db.get_orders(), opts); + // when all images are loaded in product.image_base64 + return Promise.all(get_image_promises).then(function () { + var rendered_order_lines = ""; + var rendered_payment_lines = ""; + var order_total_with_tax = self.format_currency(0); - flushed.then(resolve, reject); + if (order) { + rendered_order_lines = QWeb.render('CustomerFacingDisplayOrderLines', { + 'orderlines': order.get_orderlines(), + 'pos': self, + }); + rendered_payment_lines = QWeb.render('CustomerFacingDisplayPaymentLines', { + 'order': order, + 'pos': self, + }); + order_total_with_tax = self.format_currency(order.get_total_with_tax()); + } - return flushed; + var $rendered_html = $(rendered_html); + $rendered_html.find('.pos_orderlines_list').html(rendered_order_lines); + $rendered_html.find('.pos-total').find('.pos_total-amount').html(order_total_with_tax); + var pos_change_title = $rendered_html.find('.pos-change_title').text(); + $rendered_html.find('.pos-paymentlines').html(rendered_payment_lines); + $rendered_html.find('.pos-change_title').text(pos_change_title); + + // prop only uses the first element in a set of elements, + // and there's no guarantee that + // customer_facing_display_html is wrapped in a single + // root element. + rendered_html = _.reduce($rendered_html, function (memory, current_element) { + return memory + $(current_element).prop('outerHTML'); + }, ""); // initial memory of "" + + rendered_html = QWeb.render('CustomerFacingDisplayHead', { + origin: window.location.origin + }) + rendered_html; + return rendered_html; }); - }); - }, - - push_single_order: function (order, opts) { - opts = opts || {}; - const self = this; - const order_id = self.db.add_order(order.export_as_JSON()); - - return new Promise(function (resolve, reject) { - self.flush_mutex.exec(function () { - var order = self.db.get_order(order_id); - if (order){ - var flushed = self._flush_orders([order], opts); - } else { - var flushed = Promise.resolve([]); - } - flushed.then(resolve, reject); + }, + + // saves the order locally and try to send it to the backend. + // it returns a promise that succeeds after having tried to send the order and all the other pending orders. + push_orders: function (order, opts) { + opts = opts || {}; + var self = this; + + if (order) { + this.db.add_order(order.export_as_JSON()); + } + + return new Promise(function (resolve, reject) { + self.flush_mutex.exec(function () { + var flushed = self._flush_orders(self.db.get_orders(), opts); + + flushed.then(resolve, reject); - return flushed; + return flushed; + }); }); - }); - }, - - // saves the order locally and try to send it to the backend and make an invoice - // returns a promise that succeeds when the order has been posted and successfully generated - // an invoice. This method can fail in various ways: - // error-no-client: the order must have an associated partner_id. You can retry to make an invoice once - // this error is solved - // error-transfer: there was a connection error during the transfer. You can retry to make the invoice once - // the network connection is up - - push_and_invoice_order: function (order) { - var self = this; - var invoiced = new Promise(function (resolveInvoiced, rejectInvoiced) { - if(!order.get_client()){ - rejectInvoiced({code:400, message:'Missing Customer', data:{}}); - } - else { - var order_id = self.db.add_order(order.export_as_JSON()); + }, + + push_single_order: function (order, opts) { + opts = opts || {}; + const self = this; + const order_id = self.db.add_order(order.export_as_JSON()); + return new Promise(function (resolve, reject) { self.flush_mutex.exec(function () { - var done = new Promise(function (resolveDone, rejectDone) { - // send the order to the server - // we have a 30 seconds timeout on this push. - // FIXME: if the server takes more than 30 seconds to accept the order, - // the client will believe it wasn't successfully sent, and very bad - // things will happen as a duplicate will be sent next time - // so we must make sure the server detects and ignores duplicated orders - - var transfer = self._flush_orders([self.db.get_order(order_id)], {timeout:30000, to_invoice:true}); - - transfer.catch(function (error) { - rejectInvoiced(error); - rejectDone(); - }); + var order = self.db.get_order(order_id); + if (order) { + var flushed = self._flush_orders([order], opts); + } else { + var flushed = Promise.resolve([]); + } + flushed.then(resolve, reject); + + return flushed; + }); + }); + }, + + // saves the order locally and try to send it to the backend and make an invoice + // returns a promise that succeeds when the order has been posted and successfully generated + // an invoice. This method can fail in various ways: + // error-no-client: the order must have an associated partner_id. You can retry to make an invoice once + // this error is solved + // error-transfer: there was a connection error during the transfer. You can retry to make the invoice once + // the network connection is up - // on success, get the order id generated by the server - transfer.then(function(order_server_id){ - // generate the pdf and download it - if (order_server_id.length) { - self.do_action('point_of_sale.pos_invoice_report',{additional_context:{ - active_ids:order_server_id, - }}).then(function () { + push_and_invoice_order: function (order) { + var self = this; + var invoiced = new Promise(function (resolveInvoiced, rejectInvoiced) { + if (!order.get_client()) { + rejectInvoiced({ code: 400, message: 'Missing Customer', data: {} }); + } + else { + var order_id = self.db.add_order(order.export_as_JSON()); + + self.flush_mutex.exec(function () { + var done = new Promise(function (resolveDone, rejectDone) { + // send the order to the server + // we have a 30 seconds timeout on this push. + // FIXME: if the server takes more than 30 seconds to accept the order, + // the client will believe it wasn't successfully sent, and very bad + // things will happen as a duplicate will be sent next time + // so we must make sure the server detects and ignores duplicated orders + + var transfer = self._flush_orders([self.db.get_order(order_id)], { timeout: 30000, to_invoice: true }); + + transfer.catch(function (error) { + rejectInvoiced(error); + rejectDone(); + }); + + // on success, get the order id generated by the server + transfer.then(function (order_server_id) { + // generate the pdf and download it + if (order_server_id.length) { + self.do_action('point_of_sale.pos_invoice_report', { + additional_context: { + active_ids: order_server_id, + } + }).then(function () { + resolveInvoiced(order_server_id); + resolveDone(); + }).guardedCatch(function (error) { + rejectInvoiced({ code: 401, message: 'Backend Invoice', data: { order: order } }); + rejectDone(); + }); + } else if (order_server_id.length) { resolveInvoiced(order_server_id); resolveDone(); - }).guardedCatch(function (error) { - rejectInvoiced({code:401, message:'Backend Invoice', data:{order: order}}); + } else { + // The order has been pushed separately in batch when + // the connection came back. + // The user has to go to the backend to print the invoice + rejectInvoiced({ code: 401, message: 'Backend Invoice', data: { order: order } }); rejectDone(); - }); - } else if (order_server_id.length) { - resolveInvoiced(order_server_id); - resolveDone(); - } else { - // The order has been pushed separately in batch when - // the connection came back. - // The user has to go to the backend to print the invoice - rejectInvoiced({code:401, message:'Backend Invoice', data:{order: order}}); - rejectDone(); - } + } + }); + return done; }); - return done; }); - }); - } - }); + } + }); + + return invoiced; + }, - return invoiced; - }, + // wrapper around the _save_to_server that updates the synch status widget + // Resolves to the backend ids of the synced orders. + _flush_orders: function (orders, options) { + var self = this; + this.set_synch('connecting', orders.length); - // wrapper around the _save_to_server that updates the synch status widget - // Resolves to the backend ids of the synced orders. - _flush_orders: function(orders, options) { - var self = this; - this.set_synch('connecting', orders.length); + return this._save_to_server(orders, options).then(function (server_ids) { + self.set_synch('connected'); + for (let i = 0; i < server_ids.length; i++) { + self.validated_orders_name_server_id_map[server_ids[i].pos_reference] = server_ids[i].id; + } + return _.pluck(server_ids, 'id'); + }).catch(function (error) { + self.set_synch(self.get('failed') ? 'error' : 'disconnected'); + return Promise.reject(error); + }); + }, - return this._save_to_server(orders, options).then(function (server_ids) { - self.set_synch('connected'); - for (let i = 0; i < server_ids.length; i++) { - self.validated_orders_name_server_id_map[server_ids[i].pos_reference] = server_ids[i].id; + set_synch: function (status, pending) { + if (['connected', 'connecting', 'error', 'disconnected'].indexOf(status) === -1) { + console.error(status, ' is not a known connection state.'); } - return _.pluck(server_ids, 'id'); - }).catch(function(error){ - self.set_synch(self.get('failed') ? 'error' : 'disconnected'); - return Promise.reject(error); - }); - }, + pending = pending || this.db.get_orders().length + this.db.get_ids_to_remove_from_server().length; + this.set('synch', { status, pending }); + }, - set_synch: function(status, pending) { - if (['connected', 'connecting', 'error', 'disconnected'].indexOf(status) === -1) { - console.error(status, ' 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', { status, pending }); - }, - - // send an array of orders to the server - // available options: - // - timeout: timeout for the rpc call in ms - // returns a promise that resolves with the list of - // server generated ids for the sent orders - _save_to_server: function (orders, options) { - if (!orders || !orders.length) { - return Promise.resolve([]); - } + // send an array of orders to the server + // available options: + // - timeout: timeout for the rpc call in ms + // returns a promise that resolves with the list of + // server generated ids for the sent orders + _save_to_server: function (orders, options) { + if (!orders || !orders.length) { + return Promise.resolve([]); + } - options = options || {}; + options = options || {}; - var self = this; - var timeout = typeof options.timeout === 'number' ? options.timeout : 30000 * orders.length; + var self = this; + var timeout = typeof options.timeout === 'number' ? options.timeout : 30000 * orders.length; - // Keep the order ids that are about to be sent to the - // backend. In between create_from_ui and the success callback - // new orders may have been added to it. - var order_ids_to_sync = _.pluck(orders, 'id'); + // Keep the order ids that are about to be sent to the + // backend. In between create_from_ui and the success callback + // new orders may have been added to it. + var order_ids_to_sync = _.pluck(orders, 'id'); - // we try to send the order. shadow prevents a spinner if it takes too long. (unless we are sending an invoice, - // then we want to notify the user that we are waiting on something ) - var args = [_.map(orders, function (order) { + // we try to send the order. shadow prevents a spinner if it takes too long. (unless we are sending an invoice, + // then we want to notify the user that we are waiting on something ) + var args = [_.map(orders, function (order) { order.to_invoice = options.to_invoice || false; return order; })]; - args.push(options.draft || false); - return this.rpc({ + args.push(options.draft || false); + return this.rpc({ model: 'pos.order', method: 'create_from_ui', args: args, - kwargs: {context: this.session.user_context}, + kwargs: { context: this.session.user_context }, }, { timeout: timeout, shadow: !options.to_invoice }) - .then(function (server_ids) { - _.each(order_ids_to_sync, function (order_id) { - self.db.remove_order(order_id); - }); - self.set('failed',false); - return server_ids; - }).catch(function (reason){ - var error = reason.message; - console.warn('Failed to send orders:', orders); - if(error.code === 200 ){ // Business Logic Error, not a connection problem - // Hide error if already shown before ... - if ((!self.get('failed') || options.show_error) && !options.to_invoice) { - self.set('failed',error); - throw error; + .then(function (server_ids) { + _.each(order_ids_to_sync, function (order_id) { + self.db.remove_order(order_id); + }); + self.set('failed', false); + return server_ids; + }).catch(function (reason) { + var error = reason.message; + console.warn('Failed to send orders:', orders); + if (error.code === 200) { // Business Logic Error, not a connection problem + // Hide error if already shown before ... + if ((!self.get('failed') || options.show_error) && !options.to_invoice) { + self.set('failed', error); + throw error; + } } - } - throw error; - }); - }, + throw error; + }); + }, - /** - * Remove orders with given ids from the database. - * @param {array} 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>} 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([]); - } + /** + * Remove orders with given ids from the database. + * @param {array} 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>} 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; + var self = this; + var timeout = typeof options.timeout === 'number' ? options.timeout : 7500 * server_ids.length; - return this.rpc({ + return this.rpc({ model: 'pos.order', method: 'remove_from_ui', args: [server_ids], - kwargs: {context: this.session.user_context}, + kwargs: { context: this.session.user_context }, }, { timeout: timeout, shadow: true, }) - .then(function (data) { - return self._post_remove_from_server(server_ids, data) - }).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; + .then(function (data) { + return self._post_remove_from_server(server_ids, data) + }).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; + } } + // important to throw error here and let the rendering component handle the + // error + console.warn('Failed to remove orders:', server_ids); + throw error; + }); + }, + + // to override + _post_remove_from_server(server_ids, data) { + this.db.set_ids_removed_from_server(server_ids); + return server_ids; + }, + + scan_product: function (parsed_code) { + var selectedOrder = this.get_order(); + var product = this.db.get_product_by_barcode(parsed_code.base_code); + + if (!product) { + return false; + } + + if (parsed_code.type === 'price') { + selectedOrder.add_product(product, { price: parsed_code.value, extras: { price_manually_set: true } }); + } else if (parsed_code.type === 'weight') { + selectedOrder.add_product(product, { quantity: parsed_code.value, merge: false }); + } else if (parsed_code.type === 'discount') { + selectedOrder.add_product(product, { discount: parsed_code.value, merge: false }); + } else { + selectedOrder.add_product(product); + } + return true; + }, + + // Exports the paid orders (the ones waiting for internet connection) + export_paid_orders: function () { + return JSON.stringify({ + 'paid_orders': this.db.get_orders(), + 'session': this.pos_session.name, + 'session_id': this.pos_session.id, + 'date': (new Date()).toUTCString(), + 'version': this.version.server_version_info, + }, null, 2); + }, + + // Exports the unpaid orders (the tabs) + export_unpaid_orders: function () { + return JSON.stringify({ + 'unpaid_orders': this.db.get_unpaid_orders(), + 'session': this.pos_session.name, + 'session_id': this.pos_session.id, + 'date': (new Date()).toUTCString(), + 'version': this.version.server_version_info, + }, null, 2); + }, + + // This imports paid or unpaid orders from a json file whose + // contents are provided as the string str. + // It returns a report of what could and what could not be + // imported. + import_orders: function (str) { + var json = JSON.parse(str); + var report = { + // Number of paid orders that were imported + paid: 0, + // Number of unpaid orders that were imported + unpaid: 0, + // Orders that were not imported because they already exist (uid conflict) + unpaid_skipped_existing: 0, + // Orders that were not imported because they belong to another session + unpaid_skipped_session: 0, + // The list of session ids to which skipped orders belong. + unpaid_skipped_sessions: [], + }; + + if (json.paid_orders) { + for (var i = 0; i < json.paid_orders.length; i++) { + this.db.add_order(json.paid_orders[i].data); } - // important to throw error here and let the rendering component handle the - // error - console.warn('Failed to remove orders:', server_ids); - throw error; - }); - }, + report.paid = json.paid_orders.length; + this.push_orders(); + } - // to override - _post_remove_from_server(server_ids, data) { - this.db.set_ids_removed_from_server(server_ids); - return server_ids; - }, + if (json.unpaid_orders) { - scan_product: function(parsed_code){ - var selectedOrder = this.get_order(); - var product = this.db.get_product_by_barcode(parsed_code.base_code); + var orders = []; + var existing = this.get_order_list(); + var existing_uids = {}; + var skipped_sessions = {}; - if(!product){ - return false; - } + for (var i = 0; i < existing.length; i++) { + existing_uids[existing[i].uid] = true; + } - if(parsed_code.type === 'price'){ - selectedOrder.add_product(product, {price:parsed_code.value, extras:{price_manually_set: true}}); - }else if(parsed_code.type === 'weight'){ - selectedOrder.add_product(product, {quantity:parsed_code.value, merge:false}); - }else if(parsed_code.type === 'discount'){ - selectedOrder.add_product(product, {discount:parsed_code.value, merge:false}); - }else{ - selectedOrder.add_product(product); - } - return true; - }, - - // Exports the paid orders (the ones waiting for internet connection) - export_paid_orders: function() { - return JSON.stringify({ - 'paid_orders': this.db.get_orders(), - 'session': this.pos_session.name, - 'session_id': this.pos_session.id, - 'date': (new Date()).toUTCString(), - 'version': this.version.server_version_info, - },null,2); - }, - - // Exports the unpaid orders (the tabs) - export_unpaid_orders: function() { - return JSON.stringify({ - 'unpaid_orders': this.db.get_unpaid_orders(), - 'session': this.pos_session.name, - 'session_id': this.pos_session.id, - 'date': (new Date()).toUTCString(), - 'version': this.version.server_version_info, - },null,2); - }, - - // This imports paid or unpaid orders from a json file whose - // contents are provided as the string str. - // It returns a report of what could and what could not be - // imported. - import_orders: function(str) { - var json = JSON.parse(str); - var report = { - // Number of paid orders that were imported - paid: 0, - // Number of unpaid orders that were imported - unpaid: 0, - // Orders that were not imported because they already exist (uid conflict) - unpaid_skipped_existing: 0, - // Orders that were not imported because they belong to another session - unpaid_skipped_session: 0, - // The list of session ids to which skipped orders belong. - unpaid_skipped_sessions: [], - }; - - if (json.paid_orders) { - for (var i = 0; i < json.paid_orders.length; i++) { - this.db.add_order(json.paid_orders[i].data); - } - report.paid = json.paid_orders.length; - this.push_orders(); - } + for (var i = 0; i < json.unpaid_orders.length; i++) { + var order = json.unpaid_orders[i]; + if (order.pos_session_id !== this.pos_session.id) { + report.unpaid_skipped_session += 1; + skipped_sessions[order.pos_session_id] = true; + } else if (existing_uids[order.uid]) { + report.unpaid_skipped_existing += 1; + } else { + orders.push(new exports.Order({}, { + pos: this, + json: order, + })); + } + } - if (json.unpaid_orders) { + orders = orders.sort(function (a, b) { + return a.sequence_number - b.sequence_number; + }); - var orders = []; - var existing = this.get_order_list(); - var existing_uids = {}; - var skipped_sessions = {}; + if (orders.length) { + report.unpaid = orders.length; + this.get('orders').add(orders); + } - for (var i = 0; i < existing.length; i++) { - existing_uids[existing[i].uid] = true; + report.unpaid_skipped_sessions = _.keys(skipped_sessions); } - for (var i = 0; i < json.unpaid_orders.length; i++) { - var order = json.unpaid_orders[i]; - if (order.pos_session_id !== this.pos_session.id) { - report.unpaid_skipped_session += 1; - skipped_sessions[order.pos_session_id] = true; - } else if (existing_uids[order.uid]) { - report.unpaid_skipped_existing += 1; - } else { - orders.push(new exports.Order({},{ + return report; + }, + + _load_orders: function () { + var jsons = this.db.get_unpaid_orders(); + var orders = []; + var not_loaded_count = 0; + + for (var i = 0; i < jsons.length; i++) { + var json = jsons[i]; + if (json.pos_session_id === this.pos_session.id) { + orders.push(new exports.Order({}, { pos: this, - json: order, + json: json, })); + } else { + not_loaded_count += 1; } } - orders = orders.sort(function(a,b){ + if (not_loaded_count) { + console.info('There are ' + not_loaded_count + ' locally saved unpaid orders belonging to another session'); + } + + orders = orders.sort(function (a, b) { return a.sequence_number - b.sequence_number; }); if (orders.length) { - report.unpaid = orders.length; this.get('orders').add(orders); } - - report.unpaid_skipped_sessions = _.keys(skipped_sessions); - } - - return report; - }, - - _load_orders: function(){ - var jsons = this.db.get_unpaid_orders(); - var orders = []; - var not_loaded_count = 0; - - for (var i = 0; i < jsons.length; i++) { - var json = jsons[i]; - if (json.pos_session_id === this.pos_session.id) { - orders.push(new exports.Order({},{ - pos: this, - json: json, - })); - } else { - not_loaded_count += 1; + }, + /** + * Mirror JS method of: + * _compute_amount in addons/account/models/account.py + */ + _compute_all: function (tax, base_amount, quantity, price_exclude) { + if (price_exclude === undefined) + var price_include = tax.price_include; + else + var price_include = !price_exclude; + if (tax.amount_type === 'fixed') { + var sign_base_amount = Math.sign(base_amount) || 1; + // Since base amount has been computed with quantity + // we take the abs of quantity + // Same logic as bb72dea98de4dae8f59e397f232a0636411d37ce + return tax.amount * sign_base_amount * Math.abs(quantity); + } + if (tax.amount_type === 'percent' && !price_include) { + return base_amount * tax.amount / 100; + } + if (tax.amount_type === 'percent' && price_include) { + return base_amount - (base_amount / (1 + tax.amount / 100)); + } + if (tax.amount_type === 'division' && !price_include) { + return base_amount / (1 - tax.amount / 100) - base_amount; + } + if (tax.amount_type === 'division' && price_include) { + return base_amount - (base_amount * (tax.amount / 100)); } - } - - if (not_loaded_count) { - console.info('There are '+not_loaded_count+' locally saved unpaid orders belonging to another session'); - } + return false; + }, + /** + * Mirror JS method of: + * compute_all in addons/account/models/account.py + * + * Read comments in the python side method for more details about each sub-methods. + */ + compute_all: function (taxes, price_unit, quantity, currency_rounding, handle_price_include = true) { + var self = this; - orders = orders.sort(function(a,b){ - return a.sequence_number - b.sequence_number; - }); + // 1) Flatten the taxes. - if (orders.length) { - this.get('orders').add(orders); - } - }, - /** - * Mirror JS method of: - * _compute_amount in addons/account/models/account.py - */ - _compute_all: function(tax, base_amount, quantity, price_exclude) { - if(price_exclude === undefined) - var price_include = tax.price_include; - else - var price_include = !price_exclude; - if (tax.amount_type === 'fixed') { - var sign_base_amount = Math.sign(base_amount) || 1; - // Since base amount has been computed with quantity - // we take the abs of quantity - // Same logic as bb72dea98de4dae8f59e397f232a0636411d37ce - return tax.amount * sign_base_amount * Math.abs(quantity); - } - if (tax.amount_type === 'percent' && !price_include){ - return base_amount * tax.amount / 100; - } - if (tax.amount_type === 'percent' && price_include){ - return base_amount - (base_amount / (1 + tax.amount / 100)); - } - if (tax.amount_type === 'division' && !price_include) { - return base_amount / (1 - tax.amount / 100) - base_amount; - } - if (tax.amount_type === 'division' && price_include) { - return base_amount - (base_amount * (tax.amount / 100)); - } - return false; - }, - /** - * Mirror JS method of: - * compute_all in addons/account/models/account.py - * - * Read comments in the python side method for more details about each sub-methods. - */ - compute_all: function(taxes, price_unit, quantity, currency_rounding, handle_price_include=true) { - var self = this; + var _collect_taxes = function (taxes, all_taxes) { + taxes.sort(function (tax1, tax2) { + return tax1.sequence - tax2.sequence; + }); + _(taxes).each(function (tax) { + if (tax.amount_type === 'group') + all_taxes = _collect_taxes(tax.children_tax_ids, all_taxes); + else + all_taxes.push(tax); + }); + return all_taxes; + } + var collect_taxes = function (taxes) { + return _collect_taxes(taxes, []); + } - // 1) Flatten the taxes. + taxes = collect_taxes(taxes); - var _collect_taxes = function(taxes, all_taxes){ - taxes.sort(function (tax1, tax2) { - return tax1.sequence - tax2.sequence; - }); - _(taxes).each(function(tax){ - if(tax.amount_type === 'group') - all_taxes = _collect_taxes(tax.children_tax_ids, all_taxes); - else - all_taxes.push(tax); - }); - return all_taxes; - } - var collect_taxes = function(taxes){ - return _collect_taxes(taxes, []); - } + // 2) Deal with the rounding methods - taxes = collect_taxes(taxes); + var round_tax = this.company.tax_calculation_rounding_method != 'round_globally'; - // 2) Deal with the rounding methods + var initial_currency_rounding = currency_rounding; + if (!round_tax) + currency_rounding = currency_rounding * 0.00001; - var round_tax = this.company.tax_calculation_rounding_method != 'round_globally'; + // 3) Iterate the taxes in the reversed sequence order to retrieve the initial base of the computation. + var recompute_base = function (base_amount, fixed_amount, percent_amount, division_amount) { + return (base_amount - fixed_amount) / (1.0 + percent_amount / 100.0) * (100 - division_amount) / 100; + } - var initial_currency_rounding = currency_rounding; - if(!round_tax) - currency_rounding = currency_rounding * 0.00001; + var base = round_pr(price_unit * quantity, initial_currency_rounding); - // 3) Iterate the taxes in the reversed sequence order to retrieve the initial base of the computation. - var recompute_base = function(base_amount, fixed_amount, percent_amount, division_amount){ - return (base_amount - fixed_amount) / (1.0 + percent_amount / 100.0) * (100 - division_amount) / 100; - } + var sign = 1; + if (base < 0) { + base = -base; + sign = -1; + } else if (utils.float_is_zero(base, this.currency.decimals) && quantity < 0) { + sign = -1 + } - var base = round_pr(price_unit * quantity, initial_currency_rounding); + var total_included_checkpoints = {}; + var i = taxes.length - 1; + var store_included_tax_total = true; - var sign = 1; - if(base < 0){ - base = -base; - sign = -1; - } else if(utils.float_is_zero(base, this.currency.decimals) && quantity < 0){ - sign = -1 - } + var incl_fixed_amount = 0.0; + var incl_percent_amount = 0.0; + var incl_division_amount = 0.0; - var total_included_checkpoints = {}; - var i = taxes.length - 1; - var store_included_tax_total = true; - - var incl_fixed_amount = 0.0; - var incl_percent_amount = 0.0; - var incl_division_amount = 0.0; - - var cached_tax_amounts = {}; - if (handle_price_include){ - _(taxes.reverse()).each(function(tax){ - if(tax.include_base_amount){ - base = recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount); - incl_fixed_amount = 0.0; - incl_percent_amount = 0.0; - incl_division_amount = 0.0; - store_included_tax_total = true; - } - if(tax.price_include){ - if(tax.amount_type === 'percent') - incl_percent_amount += tax.amount; - else if(tax.amount_type === 'division') - incl_division_amount += tax.amount; - else if(tax.amount_type === 'fixed') - incl_fixed_amount += Math.abs(quantity) * tax.amount - else{ - var tax_amount = self._compute_all(tax, base, quantity); - incl_fixed_amount += tax_amount; - cached_tax_amounts[i] = tax_amount; + var cached_tax_amounts = {}; + if (handle_price_include) { + _(taxes.reverse()).each(function (tax) { + if (tax.include_base_amount) { + base = recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount); + incl_fixed_amount = 0.0; + incl_percent_amount = 0.0; + incl_division_amount = 0.0; + store_included_tax_total = true; } - if(store_included_tax_total){ - total_included_checkpoints[i] = base; - store_included_tax_total = false; + if (tax.price_include) { + if (tax.amount_type === 'percent') + incl_percent_amount += tax.amount; + else if (tax.amount_type === 'division') + incl_division_amount += tax.amount; + else if (tax.amount_type === 'fixed') + incl_fixed_amount += Math.abs(quantity) * tax.amount + else { + var tax_amount = self._compute_all(tax, base, quantity); + incl_fixed_amount += tax_amount; + cached_tax_amounts[i] = tax_amount; + } + if (store_included_tax_total) { + total_included_checkpoints[i] = base; + store_included_tax_total = false; + } } - } - i -= 1; - }); - } + i -= 1; + }); + } + + var total_excluded = round_pr(recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount), initial_currency_rounding); + var total_included = total_excluded; - var total_excluded = round_pr(recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount), initial_currency_rounding); - var total_included = total_excluded; + // 4) Iterate the taxes in the sequence order to fill missing base/amount values. - // 4) Iterate the taxes in the sequence order to fill missing base/amount values. + base = total_excluded; - base = total_excluded; + var skip_checkpoint = false; - var skip_checkpoint = false; + var taxes_vals = []; + i = 0; + var cumulated_tax_included_amount = 0; + _(taxes.reverse()).each(function (tax) { + if (!skip_checkpoint && tax.price_include && total_included_checkpoints[i] !== undefined) { + var tax_amount = total_included_checkpoints[i] - (base + cumulated_tax_included_amount); + cumulated_tax_included_amount = 0; + } else + var tax_amount = self._compute_all(tax, base, quantity, true); - var taxes_vals = []; - i = 0; - var cumulated_tax_included_amount = 0; - _(taxes.reverse()).each(function(tax){ - if(!skip_checkpoint && tax.price_include && total_included_checkpoints[i] !== undefined){ - var tax_amount = total_included_checkpoints[i] - (base + cumulated_tax_included_amount); - cumulated_tax_included_amount = 0; - }else - var tax_amount = self._compute_all(tax, base, quantity, true); + tax_amount = round_pr(tax_amount, currency_rounding); - tax_amount = round_pr(tax_amount, currency_rounding); + if (tax.price_include && total_included_checkpoints[i] === undefined) + cumulated_tax_included_amount += tax_amount; + + taxes_vals.push({ + 'id': tax.id, + 'name': tax.name, + 'amount': sign * tax_amount, + 'base': sign * round_pr(base, currency_rounding), + }); - if(tax.price_include && total_included_checkpoints[i] === undefined) - cumulated_tax_included_amount += tax_amount; + if (tax.include_base_amount) { + base += tax_amount; + if (!tax.price_include) + skip_checkpoint = true; + } - taxes_vals.push({ - 'id': tax.id, - 'name': tax.name, - 'amount': sign * tax_amount, - 'base': sign * round_pr(base, currency_rounding), + total_included += tax_amount; + i += 1; }); - if(tax.include_base_amount){ - base += tax_amount; - if(!tax.price_include) - skip_checkpoint = true; + return { + 'taxes': taxes_vals, + 'total_excluded': sign * round_pr(total_excluded, this.currency.rounding), + 'total_included': sign * round_pr(total_included, this.currency.rounding), } + }, - total_included += tax_amount; - i += 1; - }); + _map_tax_fiscal_position: function (tax, order = false) { + var self = this; + var current_order = order || this.get_order(); + var order_fiscal_position = current_order && current_order.fiscal_position; + var taxes = []; - return { - 'taxes': taxes_vals, - 'total_excluded': sign * round_pr(total_excluded, this.currency.rounding), - 'total_included': sign * round_pr(total_included, this.currency.rounding), - } - }, + if (order_fiscal_position) { + var tax_mappings = _.filter(order_fiscal_position.fiscal_position_taxes_by_id, function (fiscal_position_tax) { + return fiscal_position_tax.tax_src_id[0] === tax.id; + }); + + if (tax_mappings && tax_mappings.length) { + _.each(tax_mappings, function (tm) { + if (tm.tax_dest_id) { + var taxe = self.taxes_by_id[tm.tax_dest_id[0]]; + if (taxe) { + taxes.push(taxe); + } + } + }); + } else { + taxes.push(tax); + } + } else { + taxes.push(tax); + } - _map_tax_fiscal_position: function(tax, order = false) { - var self = this; - var current_order = order || this.get_order(); - var order_fiscal_position = current_order && current_order.fiscal_position; - var taxes = []; + return taxes; + }, - if (order_fiscal_position) { - var tax_mappings = _.filter(order_fiscal_position.fiscal_position_taxes_by_id, function (fiscal_position_tax) { - return fiscal_position_tax.tax_src_id[0] === tax.id; + get_taxes_after_fp: function (taxes_ids, order = false) { + var self = this; + var taxes = this.taxes; + var product_taxes = []; + _(taxes_ids).each(function (el) { + var tax = _.detect(taxes, function (t) { + return t.id === el; + }); + product_taxes.push.apply(product_taxes, self._map_tax_fiscal_position(tax, order)); }); + product_taxes = _.uniq(product_taxes, function (tax) { return tax.id; }); + return product_taxes; + }, - if (tax_mappings && tax_mappings.length) { - _.each(tax_mappings, function(tm) { - if (tm.tax_dest_id) { - var taxe = self.taxes_by_id[tm.tax_dest_id[0]]; - if (taxe) { - taxes.push(taxe); - } - } + /** + * Directly calls the requested service, instead of triggering a + * 'call_service' event up, which wouldn't work as services have no parent + * + * @param {OdooEvent} ev + */ + _trigger_up: function (ev) { + if (ev.is_stopped()) { + return; + } + const payload = ev.data; + if (ev.name === 'call_service') { + let args = payload.args || []; + if (payload.service === 'ajax' && payload.method === 'rpc') { + // ajax service uses an extra 'target' argument for rpc + args = args.concat(ev.target); + } + const service = this.env.services[payload.service]; + const result = service[payload.method].apply(service, args); + payload.callback(result); + } + }, + + electronic_payment_interfaces: {}, + + format_currency: function (amount, precision) { + var currency = + this && this.currency + ? this.currency + : { symbol: '$', position: 'after', rounding: 0.01, decimals: 2 }; + + amount = this.format_currency_no_symbol(amount, precision, currency); + + if (currency.position === 'after') { + return amount + ' ' + (currency.symbol || ''); + } else { + return (currency.symbol || '') + ' ' + amount; + } + }, + + format_currency_no_symbol: function (amount, precision, currency) { + if (!currency) { + currency = + this && this.currency + ? this.currency + : { symbol: '$', position: 'after', rounding: 0.01, decimals: 2 }; + } + var decimals = currency.decimals; + + if (precision && this.dp[precision] !== undefined) { + decimals = this.dp[precision]; + } + + if (typeof amount === 'number') { + amount = round_di(amount, decimals).toFixed(decimals); + amount = field_utils.format.float(round_di(amount, decimals), { + digits: [69, decimals], }); - } else{ - taxes.push(tax); } - } else { - taxes.push(tax); - } - return taxes; - }, + return amount; + }, - get_taxes_after_fp: function(taxes_ids, order = false){ - var self = this; - var taxes = this.taxes; - var product_taxes = []; - _(taxes_ids).each(function(el){ - var tax = _.detect(taxes, function(t){ - return t.id === el; - }); - product_taxes.push.apply(product_taxes, self._map_tax_fiscal_position(tax, order)); - }); - product_taxes = _.uniq(product_taxes, function(tax) { return tax.id; }); - return product_taxes; - }, + format_pr: function (value, precision) { + var decimals = + precision > 0 + ? Math.max(0, Math.ceil(Math.log(1.0 / precision) / Math.log(10))) + : 0; + return value.toFixed(decimals); + }, + + /** + * (value = 1.0000, decimals = 2) => '1' + * (value = 1.1234, decimals = 2) => '1.12' + * @param {number} value amount to format + */ + formatFixed: function (value) { + const currency = this.currency || { decimals: 2 }; + return `${Number(value.toFixed(currency.decimals || 0))}`; + }, + + disallowLineQuantityChange() { + return false; + }, + + getCurrencySymbol() { + return this.currency ? this.currency.symbol : '$'; + }, + + htmlToImgLetterRendering() { + return false; + }, + }); /** - * Directly calls the requested service, instead of triggering a - * 'call_service' event up, which wouldn't work as services have no parent + * Call this function to map your PaymentInterface implementation to + * the use_payment_terminal field. When the POS loads it will take + * care of instantiating your interface and setting it on the right + * payment methods. + * + * @param {string} use_payment_terminal - value used in the + * use_payment_terminal selection field * - * @param {OdooEvent} ev + * @param {Object} ImplementedPaymentInterface - implemented + * PaymentInterface */ - _trigger_up: function (ev) { - if (ev.is_stopped()) { - return; + exports.register_payment_method = function (use_payment_terminal, ImplementedPaymentInterface) { + exports.PosModel.prototype.electronic_payment_interfaces[use_payment_terminal] = ImplementedPaymentInterface; + }; + + // Add fields to the list of read fields when a model is loaded + // by the point of sale. + // e.g: module.load_fields("product.product",['price','category']) + + exports.load_fields = function (model_name, fields) { + if (!(fields instanceof Array)) { + fields = [fields]; + } + + var models = exports.PosModel.prototype.models; + for (var i = 0; i < models.length; i++) { + var model = models[i]; + if (model.model === model_name) { + // if 'fields' is empty all fields are loaded, so we do not need + // to modify the array + if ((model.fields instanceof Array) && model.fields.length > 0) { + model.fields = model.fields.concat(fields || []); + } + } + } + }; + + // Loads openerp models at the point of sale startup. + // load_models take an array of model loader declarations. + // - The models will be loaded in the array order. + // - If no openerp model name is provided, no server data + // will be loaded, but the system can be used to preprocess + // data before load. + // - loader arguments can be functions that return a dynamic + // value. The function takes the PosModel as the first argument + // and a temporary object that is shared by all models, and can + // be used to store transient information between model loads. + // - There is no dependency management. The models must be loaded + // in the right order. Newly added models are loaded at the end + // but the after / before options can be used to load directly + // before / after another model. + // + // models: [{ + // model: [string] the name of the openerp model to load. + // label: [string] The label displayed during load. + // fields: [[string]|function] the list of fields to be loaded. + // Empty Array / Null loads all fields. + // order: [[string]|function] the models will be ordered by + // the provided fields + // domain: [domain|function] the domain that determines what + // models need to be loaded. Null loads everything + // ids: [[id]|function] the id list of the models that must + // be loaded. Overrides domain. + // context: [Dict|function] the openerp context for the model read + // condition: [function] do not load the models if it evaluates to + // false. + // loaded: [function(self,model)] this function is called once the + // models have been loaded, with the data as second argument + // if the function returns a promise, the next model will + // wait until it resolves before loading. + // }] + // + // options: + // before: [string] The model will be loaded before the named models + // (applies to both model name and label) + // after: [string] The model will be loaded after the (last loaded) + // named model. (applies to both model name and label) + // + exports.load_models = function (models, options) { + options = options || {}; + if (!(models instanceof Array)) { + models = [models]; } - const payload = ev.data; - if (ev.name === 'call_service') { - let args = payload.args || []; - if (payload.service === 'ajax' && payload.method === 'rpc') { - // ajax service uses an extra 'target' argument for rpc - args = args.concat(ev.target); - } - const service = this.env.services[payload.service]; - const result = service[payload.method].apply(service, args); - payload.callback(result); + + var pmodels = exports.PosModel.prototype.models; + var index = pmodels.length; + if (options.before) { + for (var i = 0; i < pmodels.length; i++) { + if (pmodels[i].model === options.before || + pmodels[i].label === options.before) { + index = i; + break; + } + } + } else if (options.after) { + for (var i = 0; i < pmodels.length; i++) { + if (pmodels[i].model === options.after || + pmodels[i].label === options.after) { + index = i + 1; + } + } } - }, + pmodels.splice.apply(pmodels, [index, 0].concat(models)); + }; - electronic_payment_interfaces: {}, + exports.Product = Backbone.Model.extend({ + initialize: function (attr, options) { + _.extend(this, options); + }, + isAllowOnlyOneLot: function () { + const productUnit = this.get_unit(); + return this.tracking === 'lot' || !productUnit || !productUnit.is_pos_groupable; + }, + get_unit: function () { + var unit_id = this.uom_id; + if (!unit_id) { + return undefined; + } + unit_id = unit_id[0]; + if (!this.pos) { + return undefined; + } + return this.pos.units_by_id[unit_id]; + }, + // 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, price_extra) { + var self = this; + var date = moment(); + + // In case of nested pricelists, it is necessary that all pricelists are made available in + // the POS. Display a basic alert to the user in this case. + if (pricelist === undefined) { + alert(_t( + 'An error occurred when loading product prices. ' + + 'Make sure all pricelists are available in the POS.' + )); + } + + 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.utc(item.date_start).isSameOrBefore(date)) && + (!item.date_end || moment.utc(item.date_end).isSameOrAfter(date)); + }); - format_currency: function(amount, precision) { - var currency = - this && this.currency - ? this.currency - : { symbol: '$', position: 'after', rounding: 0.01, decimals: 2 }; + var price = self.lst_price; + if (price_extra) { + price += price_extra; + } + _.find(pricelist_items, function (rule) { + if (rule.min_quantity && quantity < rule.min_quantity) { + return false; + } - amount = this.format_currency_no_symbol(amount, precision, currency); + if (rule.base === 'pricelist') { + price = self.get_price(rule.base_pricelist, quantity); + } else if (rule.base === 'standard_price') { + price = self.standard_price; + } - if (currency.position === 'after') { - return amount + ' ' + (currency.symbol || ''); - } else { - return (currency.symbol || '') + ' ' + amount; - } - }, + 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; + } - format_currency_no_symbol: function(amount, precision, currency) { - if (!currency) { - currency = - this && this.currency - ? this.currency - : { symbol: '$', position: 'after', rounding: 0.01, decimals: 2 }; - } - var decimals = currency.decimals; + return false; + }); - if (precision && this.dp[precision] !== undefined) { - decimals = this.dp[precision]; - } + // 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; + }, - if (typeof amount === 'number') { - amount = round_di(amount, decimals).toFixed(decimals); - amount = field_utils.format.float(round_di(amount, decimals), { - digits: [69, decimals], + get_display_price: function (pricelist, quantity) { + if (this.pos.config.iface_tax_included === 'total') { + const taxes = this.pos.get_taxes_after_fp(this.taxes_id); + const allPrices = this.pos.compute_all(taxes, this.get_price(pricelist, quantity), 1, this.pos.currency.rounding); + return allPrices.total_included; + } else { + return this.get_price(pricelist, quantity); + } + } + }); + + var orderline_id = 1; + + // An orderline represent one element of the content of a client's shopping cart. + // An orderline contains a product, its quantity, its price, discount. etc. + // An Order contains zero or more Orderlines. + exports.Orderline = Backbone.Model.extend({ + initialize: function (attr, options) { + this.pos = options.pos; + this.order = options.order; + if (options.json) { + try { + this.init_from_JSON(options.json); + } catch (error) { + console.error('ERROR: attempting to recover product ID', options.json.product_id, + 'not available in the point of sale. Correct the product or clean the browser cache.'); + } + return; + } + this.product = options.product; + this.set_product_lot(this.product); + this.set_quantity(1); + this.discount = 0; + this.discountStr = '0'; + this.selected = false; + this.description = ''; + this.price_extra = 0; + this.full_product_name = ''; + this.id = orderline_id++; + this.price_manually_set = false; + + 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); + this.set_product_lot(this.product); + this.price = json.price_unit; + this.set_discount(json.discount); + this.set_quantity(json.qty, 'do not recompute unit price'); + this.set_description(json.description); + this.set_price_extra(json.price_extra); + this.set_full_product_name(json.full_product_name); + 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++) { + var packlotline = pack_lot_lines[i][2]; + var pack_lot_line = new exports.Packlotline({}, { 'json': _.extend({ ...packlotline }, { 'order_line': this }) }); + this.pack_lot_lines.add(pack_lot_line); + } + }, + clone: function () { + var orderline = new exports.Orderline({}, { + pos: this.pos, + order: this.order, + product: this.product, + price: this.price, }); - } + orderline.order = null; + orderline.quantity = this.quantity; + orderline.quantityStr = this.quantityStr; + orderline.discount = this.discount; + orderline.price = this.price; + orderline.selected = false; + orderline.price_manually_set = this.price_manually_set; + return orderline; + }, + getPackLotLinesToEdit: function (isAllowOnlyOneLot) { + const currentPackLotLines = this.pack_lot_lines.models; + let nExtraLines = Math.abs(this.quantity) - currentPackLotLines.length; + nExtraLines = Math.ceil(nExtraLines); + nExtraLines = nExtraLines > 0 ? nExtraLines : 1; + const tempLines = currentPackLotLines + .map(lotLine => ({ + id: lotLine.cid, + text: lotLine.get('lot_name'), + })) + .concat( + Array.from(Array(nExtraLines)).map(_ => ({ + text: '', + })) + ); + return isAllowOnlyOneLot ? [tempLines[0]] : tempLines; + }, + /** + * @param { modifiedPackLotLines, newPackLotLines } + * @param {Object} modifiedPackLotLines key-value pair of String (the cid) & String (the new lot_name) + * @param {Array} newPackLotLines array of { lot_name: String } + */ + setPackLotLines: function ({ modifiedPackLotLines, newPackLotLines }) { + // Set the new values for modified lot lines. + let lotLinesToRemove = []; + for (let lotLine of this.pack_lot_lines.models) { + const modifiedLotName = modifiedPackLotLines[lotLine.cid]; + if (modifiedLotName) { + lotLine.set({ lot_name: modifiedLotName }); + } else { + // We should not call lotLine.remove() here because + // we don't want to mutate the array while looping thru it. + lotLinesToRemove.push(lotLine); + } + } - return amount; - }, + // Remove those that needed to be removed. + for (let lotLine of lotLinesToRemove) { + lotLine.remove(); + } - format_pr: function(value, precision) { - var decimals = - precision > 0 - ? Math.max(0, Math.ceil(Math.log(1.0 / precision) / Math.log(10))) - : 0; - return value.toFixed(decimals); - }, + // Create new pack lot lines. + let newPackLotLine; + for (let newLotLine of newPackLotLines) { + newPackLotLine = new exports.Packlotline({}, { order_line: this }); + newPackLotLine.set({ lot_name: newLotLine.lot_name }); + this.pack_lot_lines.add(newPackLotLine); + } - /** - * (value = 1.0000, decimals = 2) => '1' - * (value = 1.1234, decimals = 2) => '1.12' - * @param {number} value amount to format - */ - formatFixed: function(value) { - const currency = this.currency || { decimals: 2 }; - return `${Number(value.toFixed(currency.decimals || 0))}`; - }, - - disallowLineQuantityChange() { - return false; - }, - - getCurrencySymbol() { - return this.currency ? this.currency.symbol : '$'; - }, - - htmlToImgLetterRendering() { - return false; - }, -}); + // Set the quantity of the line based on number of pack lots. + if (!this.product.to_weight) { + this.pack_lot_lines.set_quantity_by_lot(); + } + }, + set_product_lot: function (product) { + this.has_product_lot = product.tracking !== 'none'; + this.pack_lot_lines = this.has_product_lot && new PacklotlineCollection(null, { 'order_line': this }); + }, + // sets a discount [0,100]% + set_discount: function (discount) { + var parsed_discount = typeof (discount) === 'number' ? discount : isNaN(parseFloat(discount)) ? 0 : field_utils.parse.float('' + discount); + var disc = Math.min(Math.max(parsed_discount || 0, 0), 100); + this.discount = disc; + this.discountStr = '' + disc; + this.trigger('change', this); + }, + // returns the discount [0,100]% + get_discount: function () { + return this.discount; + }, + get_discount_str: function () { + return this.discountStr; + }, + set_description: function (description) { + this.description = description || ''; + }, + set_price_extra: function (price_extra) { + this.price_extra = parseFloat(price_extra) || 0.0; + }, + set_full_product_name: function (full_product_name) { + this.full_product_name = full_product_name || ''; + }, + get_price_extra: function () { + return this.price_extra; + }, + // 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, keep_price) { + this.order.assert_editable(); + if (quantity === 'remove') { + this.order.remove_orderline(this); + return; + } else { + var quant = typeof (quantity) === 'number' ? quantity : (field_utils.parse.float('' + quantity) || 0); + var unit = this.get_unit(); + if (unit) { + if (unit.rounding) { + var decimals = this.pos.dp['Product Unit of Measure']; + var rounding = Math.max(unit.rounding, Math.pow(10, -decimals)); + this.quantity = round_pr(quant, rounding); + this.quantityStr = field_utils.format.float(this.quantity, { digits: [69, decimals] }); + } else { + this.quantity = round_pr(quant, 1); + this.quantityStr = this.quantity.toFixed(0); + } + } else { + this.quantity = quant; + this.quantityStr = '' + this.quantity; + } + } -/** - * Call this function to map your PaymentInterface implementation to - * the use_payment_terminal field. When the POS loads it will take - * care of instantiating your interface and setting it on the right - * payment methods. - * - * @param {string} use_payment_terminal - value used in the - * use_payment_terminal selection field - * - * @param {Object} ImplementedPaymentInterface - implemented - * PaymentInterface - */ -exports.register_payment_method = function(use_payment_terminal, ImplementedPaymentInterface) { - exports.PosModel.prototype.electronic_payment_interfaces[use_payment_terminal] = ImplementedPaymentInterface; -}; - -// Add fields to the list of read fields when a model is loaded -// by the point of sale. -// e.g: module.load_fields("product.product",['price','category']) - -exports.load_fields = function(model_name, fields) { - if (!(fields instanceof Array)) { - fields = [fields]; - } - - var models = exports.PosModel.prototype.models; - for (var i = 0; i < models.length; i++) { - var model = models[i]; - if (model.model === model_name) { - // if 'fields' is empty all fields are loaded, so we do not need - // to modify the array - if ((model.fields instanceof Array) && model.fields.length > 0) { - model.fields = model.fields.concat(fields || []); + // just like in sale.order changing the quantity will recompute the unit price + if (!keep_price && !this.price_manually_set && !( + this.pos.config.product_configurator && _.some(this.product.attribute_line_ids, (id) => id in this.pos.attributes_by_ptal_id))) { + this.set_unit_price(this.product.get_price(this.order.pricelist, this.get_quantity(), this.get_price_extra())); + this.order.fix_tax_included_price(this); } - } - } -}; - -// Loads openerp models at the point of sale startup. -// load_models take an array of model loader declarations. -// - The models will be loaded in the array order. -// - If no openerp model name is provided, no server data -// will be loaded, but the system can be used to preprocess -// data before load. -// - loader arguments can be functions that return a dynamic -// value. The function takes the PosModel as the first argument -// and a temporary object that is shared by all models, and can -// be used to store transient information between model loads. -// - There is no dependency management. The models must be loaded -// in the right order. Newly added models are loaded at the end -// but the after / before options can be used to load directly -// before / after another model. -// -// models: [{ -// model: [string] the name of the openerp model to load. -// label: [string] The label displayed during load. -// fields: [[string]|function] the list of fields to be loaded. -// Empty Array / Null loads all fields. -// order: [[string]|function] the models will be ordered by -// the provided fields -// domain: [domain|function] the domain that determines what -// models need to be loaded. Null loads everything -// ids: [[id]|function] the id list of the models that must -// be loaded. Overrides domain. -// context: [Dict|function] the openerp context for the model read -// condition: [function] do not load the models if it evaluates to -// false. -// loaded: [function(self,model)] this function is called once the -// models have been loaded, with the data as second argument -// if the function returns a promise, the next model will -// wait until it resolves before loading. -// }] -// -// options: -// before: [string] The model will be loaded before the named models -// (applies to both model name and label) -// after: [string] The model will be loaded after the (last loaded) -// named model. (applies to both model name and label) -// -exports.load_models = function(models,options) { - options = options || {}; - if (!(models instanceof Array)) { - models = [models]; - } - - var pmodels = exports.PosModel.prototype.models; - var index = pmodels.length; - if (options.before) { - for (var i = 0; i < pmodels.length; i++) { - if ( pmodels[i].model === options.before || - pmodels[i].label === options.before ){ - index = i; - break; + this.trigger('change', this); + }, + // return the quantity of product + get_quantity: function () { + return this.quantity; + }, + get_quantity_str: function () { + return this.quantityStr; + }, + get_quantity_str_with_unit: function () { + var unit = this.get_unit(); + if (unit && !unit.is_pos_groupable) { + return this.quantityStr + ' ' + unit.name; + } else { + return this.quantityStr; } - } - } else if (options.after) { - for (var i = 0; i < pmodels.length; i++) { - if ( pmodels[i].model === options.after || - pmodels[i].label === options.after ){ - index = i + 1; + }, + + get_lot_lines: function () { + return this.pack_lot_lines.models; + }, + + get_required_number_of_lots: function () { + var lots_required = 1; + + if (this.product.tracking == 'serial') { + lots_required = Math.abs(this.quantity); } - } - } - pmodels.splice.apply(pmodels,[index,0].concat(models)); -}; - -exports.Product = Backbone.Model.extend({ - initialize: function(attr, options){ - _.extend(this, options); - }, - isAllowOnlyOneLot: function() { - const productUnit = this.get_unit(); - return this.tracking === 'lot' || !productUnit || !productUnit.is_pos_groupable; - }, - get_unit: function() { - var unit_id = this.uom_id; - if(!unit_id){ - return undefined; - } - unit_id = unit_id[0]; - if(!this.pos){ - return undefined; - } - return this.pos.units_by_id[unit_id]; - }, - // 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, price_extra){ - var self = this; - var date = moment(); - - // In case of nested pricelists, it is necessary that all pricelists are made available in - // the POS. Display a basic alert to the user in this case. - if (pricelist === undefined) { - alert(_t( - 'An error occurred when loading product prices. ' + - 'Make sure all pricelists are available in the POS.' - )); - } - var category_ids = []; - var category = this.categ; - while (category) { - category_ids.push(category.id); - category = category.parent; - } + return lots_required; + }, + + has_valid_product_lot: function () { + if (!this.has_product_lot) { + return true; + } + var valid_product_lot = this.pack_lot_lines.get_valid_lots(); + return this.get_required_number_of_lots() === valid_product_lot.length; + }, + + // return the unit of measure of the product + get_unit: function () { + return this.product.get_unit(); + }, + // return the product of this orderline + get_product: function () { + return this.product; + }, + get_full_product_name: function () { + if (this.full_product_name) { + return this.full_product_name + } + var full_name = this.product.display_name; + if (this.description) { + full_name += ` (${this.description})`; + } + return full_name; + }, + // selects or deselects this orderline + set_selected: function (selected) { + this.selected = selected; + // this trigger also triggers the change event of the collection. + this.trigger('change', this); + this.trigger('new-orderline-selected'); + }, + // returns true if this orderline is selected + is_selected: function () { + return this.selected; + }, + // when we add an new orderline we want to merge it with the last line to see reduce the number of items + // in the orderline. This returns true if it makes sense to merge the two + can_be_merged_with: function (orderline) { + var price = parseFloat(round_di(this.price || 0, this.pos.dp['Product Price']).toFixed(this.pos.dp['Product Price'])); + var order_line_price = orderline.get_product().get_price(orderline.order.pricelist, this.get_quantity()); + order_line_price = round_di(orderline.compute_fixed_price(order_line_price), this.pos.currency.decimals); + if (this.get_product().id !== orderline.get_product().id) { //only orderline of the same product can be merged + return false; + } else if (!this.get_unit() || !this.get_unit().is_pos_groupable) { + return false; + } else if (this.get_discount() > 0) { // we don't merge discounted orderlines + return false; + } else if (!utils.float_is_zero(price - order_line_price - orderline.get_price_extra(), + this.pos.currency.decimals)) { + return false; + } else if (this.product.tracking == 'lot' && (this.pos.picking_type.use_create_lots || this.pos.picking_type.use_existing_lots)) { + return false; + } else if (this.description !== orderline.description) { + return false; + } else { + return true; + } + }, + merge: function (orderline) { + this.order.assert_editable(); + this.set_quantity(this.get_quantity() + orderline.get_quantity()); + }, + export_as_JSON: function () { + var pack_lot_ids = []; + if (this.has_product_lot) { + this.pack_lot_lines.each(_.bind(function (item) { + return pack_lot_ids.push([0, 0, item.export_as_JSON()]); + }, this)); + } + return { + qty: this.get_quantity(), + price_unit: this.get_unit_price(), + price_subtotal: this.get_price_without_tax(), + price_subtotal_incl: this.get_price_with_tax(), + discount: this.get_discount(), + product_id: this.get_product().id, + tax_ids: [[6, false, _.map(this.get_applicable_taxes(), function (tax) { return tax.id; })]], + id: this.id, + pack_lot_ids: pack_lot_ids, + description: this.description, + full_product_name: this.get_full_product_name(), + price_extra: this.get_price_extra(), + }; + }, + //used to create a json of the ticket, to be sent to the printer + export_for_printing: function () { + return { + id: this.id, + quantity: this.get_quantity(), + unit_name: this.get_unit().name, + is_in_unit: this.get_unit().id == this.pos.uom_unit_id, + price: this.get_unit_display_price(), + discount: this.get_discount(), + product_name: this.get_product().display_name, + product_name_wrapped: this.generate_wrapped_product_name(), + price_lst: this.get_lst_price(), + display_discount_policy: this.display_discount_policy(), + price_display_one: this.get_display_price_one(), + price_display: this.get_display_price(), + price_with_tax: this.get_price_with_tax(), + price_without_tax: this.get_price_without_tax(), + price_with_tax_before_discount: this.get_price_with_tax_before_discount(), + tax: this.get_tax(), + product_description: this.get_product().description, + product_description_sale: this.get_product().description_sale, + pack_lot_lines: this.get_lot_lines() + }; + }, + generate_wrapped_product_name: function () { + var MAX_LENGTH = 24; // 40 * line ratio of .6 + var wrapped = []; + var name = this.get_full_product_name(); + var current_line = ""; + + while (name.length > 0) { + var space_index = name.indexOf(" "); + + if (space_index === -1) { + space_index = name.length; + } + + if (current_line.length + space_index > MAX_LENGTH) { + if (current_line.length) { + wrapped.push(current_line); + } + current_line = ""; + } + + current_line += name.slice(0, space_index + 1); + name = name.slice(space_index + 1); + } - 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.utc(item.date_start).isSameOrBefore(date)) && - (! item.date_end || moment.utc(item.date_end).isSameOrAfter(date)); - }); - - var price = self.lst_price; - if (price_extra){ - price += price_extra; - } - _.find(pricelist_items, function (rule) { - if (rule.min_quantity && quantity < rule.min_quantity) { - return false; + if (current_line.length) { + wrapped.push(current_line); } - if (rule.base === 'pricelist') { - price = self.get_price(rule.base_pricelist, quantity); - } else if (rule.base === 'standard_price') { - price = self.standard_price; + return wrapped; + }, + // changes the base price of the product for this orderline + set_unit_price: function (price) { + this.order.assert_editable(); + var parsed_price = !isNaN(price) ? + price : + isNaN(parseFloat(price)) ? 0 : field_utils.parse.float('' + price) + this.price = round_di(parsed_price || 0, this.pos.dp['Product Price']); + this.trigger('change', this); + }, + get_unit_price: function () { + var digits = this.pos.dp['Product Price']; + // round and truncate to mimic _symbol_set behavior + return parseFloat(round_di(this.price || 0, digits).toFixed(digits)); + }, + get_unit_display_price: function () { + if (this.pos.config.iface_tax_included === 'total') { + var quantity = this.quantity; + this.quantity = 1.0; + var price = this.get_all_prices().priceWithTax; + this.quantity = quantity; + return price; + } else { + return this.get_unit_price(); } + }, + get_base_price: function () { + var rounding = this.pos.currency.rounding; + return round_pr(this.get_unit_price() * this.get_quantity() * (1 - this.get_discount() / 100), rounding); + }, + get_taxes_after_fp: function (taxes_ids) { + return this.pos.get_taxes_after_fp(taxes_ids, this.order); + }, + get_display_price_one: function () { + var rounding = this.pos.currency.rounding; + var price_unit = this.get_unit_price(); + if (this.pos.config.iface_tax_included !== 'total') { + return round_pr(price_unit * (1.0 - (this.get_discount() / 100.0)), rounding); + } else { + var product = this.get_product(); + var taxes_ids = product.taxes_id; + var product_taxes = this.get_taxes_after_fp(taxes_ids); + var all_taxes = this.compute_all(product_taxes, price_unit, 1, this.pos.currency.rounding); - 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; + return round_pr(all_taxes.total_included * (1 - this.get_discount() / 100), rounding); + } + }, + get_display_price: function () { + if (this.pos.config.iface_tax_included === 'total') { + return this.get_price_with_tax(); } 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); + return this.get_base_price(); + } + }, + get_taxed_lst_unit_price: function () { + var lst_price = this.compute_fixed_price(this.get_lst_price()); + if (this.pos.config.iface_tax_included === 'total') { + var product = this.get_product(); + var taxes_ids = product.taxes_id; + var product_taxes = this.get_taxes_after_fp(taxes_ids); + return this.compute_all(product_taxes, lst_price, 1, this.pos.currency.rounding).total_included; + } + return lst_price; + }, + get_price_without_tax: function () { + return this.get_all_prices().priceWithoutTax; + }, + get_price_with_tax: function () { + return this.get_all_prices().priceWithTax; + }, + get_price_with_tax_before_discount: function () { + return this.get_all_prices().priceWithTaxBeforeDiscount; + }, + get_tax: function () { + return this.get_all_prices().tax; + }, + get_applicable_taxes: function () { + var i; + // Shenaningans because we need + // to keep the taxes ordering. + var ptaxes_ids = this.get_product().taxes_id; + var ptaxes_set = {}; + for (i = 0; i < ptaxes_ids.length; i++) { + ptaxes_set[ptaxes_ids[i]] = true; + } + var taxes = []; + for (i = 0; i < this.pos.taxes.length; i++) { + if (ptaxes_set[this.pos.taxes[i].id]) { + taxes.push(this.pos.taxes[i]); } - if (rule.price_max_margin) { - price = Math.min(price, price_limit + rule.price_max_margin); + } + return taxes; + }, + get_tax_details: function () { + return this.get_all_prices().taxDetails; + }, + get_taxes: function () { + var taxes_ids = this.get_product().taxes_id; + var taxes = []; + for (var i = 0; i < taxes_ids.length; i++) { + if (this.pos.taxes_by_id[taxes_ids[i]]) { + taxes.push(this.pos.taxes_by_id[taxes_ids[i]]); } - return true; } + return taxes; + }, + _map_tax_fiscal_position: function (tax, order = false) { + return this.pos._map_tax_fiscal_position(tax, order); + }, + /** + * Mirror JS method of: + * _compute_amount in addons/account/models/account.py + */ + _compute_all: function (tax, base_amount, quantity, price_exclude) { + return this.pos._compute_all(tax, base_amount, quantity, price_exclude) + }, + /** + * Mirror JS method of: + * compute_all in addons/account/models/account.py + * + * Read comments in the python side method for more details about each sub-methods. + */ + compute_all: function (taxes, price_unit, quantity, currency_rounding, handle_price_include = true) { + return this.pos.compute_all(taxes, price_unit, quantity, currency_rounding, handle_price_include) - 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; - }, - - get_display_price: function(pricelist, quantity) { - if (this.pos.config.iface_tax_included === 'total') { - const taxes = this.pos.get_taxes_after_fp(this.taxes_id); - const allPrices = this.pos.compute_all(taxes, this.get_price(pricelist, quantity), 1, this.pos.currency.rounding); - return allPrices.total_included; - } else { - return this.get_price(pricelist, quantity); - } - } -}); + }, + get_all_prices: function () { -var orderline_id = 1; - -// An orderline represent one element of the content of a client's shopping cart. -// An orderline contains a product, its quantity, its price, discount. etc. -// An Order contains zero or more Orderlines. -exports.Orderline = Backbone.Model.extend({ - initialize: function(attr,options){ - this.pos = options.pos; - this.order = options.order; - if (options.json) { - try { - this.init_from_JSON(options.json); - } catch(error) { - console.error('ERROR: attempting to recover product ID', options.json.product_id, - 'not available in the point of sale. Correct the product or clean the browser cache.'); - } - return; - } - this.product = options.product; - this.set_product_lot(this.product); - this.set_quantity(1); - this.discount = 0; - this.discountStr = '0'; - this.selected = false; - this.description = ''; - this.price_extra = 0; - this.full_product_name = ''; - this.id = orderline_id++; - this.price_manually_set = false; - - 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); - this.set_product_lot(this.product); - this.price = json.price_unit; - this.set_discount(json.discount); - this.set_quantity(json.qty, 'do not recompute unit price'); - this.set_description(json.description); - this.set_price_extra(json.price_extra); - this.set_full_product_name(json.full_product_name); - 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++) { - var packlotline = pack_lot_lines[i][2]; - var pack_lot_line = new exports.Packlotline({}, {'json': _.extend({...packlotline}, {'order_line':this})}); - this.pack_lot_lines.add(pack_lot_line); - } - }, - clone: function(){ - var orderline = new exports.Orderline({},{ - pos: this.pos, - order: this.order, - product: this.product, - price: this.price, - }); - orderline.order = null; - orderline.quantity = this.quantity; - orderline.quantityStr = this.quantityStr; - orderline.discount = this.discount; - orderline.price = this.price; - orderline.selected = false; - orderline.price_manually_set = this.price_manually_set; - return orderline; - }, - getPackLotLinesToEdit: function(isAllowOnlyOneLot) { - const currentPackLotLines = this.pack_lot_lines.models; - let nExtraLines = Math.abs(this.quantity) - currentPackLotLines.length; - nExtraLines = Math.ceil(nExtraLines); - nExtraLines = nExtraLines > 0 ? nExtraLines : 1; - const tempLines = currentPackLotLines - .map(lotLine => ({ - id: lotLine.cid, - text: lotLine.get('lot_name'), - })) - .concat( - Array.from(Array(nExtraLines)).map(_ => ({ - text: '', - })) - ); - return isAllowOnlyOneLot ? [tempLines[0]] : tempLines; - }, - /** - * @param { modifiedPackLotLines, newPackLotLines } - * @param {Object} modifiedPackLotLines key-value pair of String (the cid) & String (the new lot_name) - * @param {Array} newPackLotLines array of { lot_name: String } - */ - setPackLotLines: function({ modifiedPackLotLines, newPackLotLines }) { - // Set the new values for modified lot lines. - let lotLinesToRemove = []; - for (let lotLine of this.pack_lot_lines.models) { - const modifiedLotName = modifiedPackLotLines[lotLine.cid]; - if (modifiedLotName) { - lotLine.set({ lot_name: modifiedLotName }); - } else { - // We should not call lotLine.remove() here because - // we don't want to mutate the array while looping thru it. - lotLinesToRemove.push(lotLine); - } - } + var price_unit = this.get_unit_price() * (1.0 - (this.get_discount() / 100.0)); + var taxtotal = 0; - // Remove those that needed to be removed. - for (let lotLine of lotLinesToRemove) { - lotLine.remove(); - } + var product = this.get_product(); + var taxes_ids = _.filter(product.taxes_id, t => t in this.pos.taxes_by_id); + var taxdetail = {}; + var product_taxes = this.get_taxes_after_fp(taxes_ids); - // Create new pack lot lines. - let newPackLotLine; - for (let newLotLine of newPackLotLines) { - newPackLotLine = new exports.Packlotline({}, { order_line: this }); - newPackLotLine.set({ lot_name: newLotLine.lot_name }); - this.pack_lot_lines.add(newPackLotLine); - } + var all_taxes = this.compute_all(product_taxes, price_unit, this.get_quantity(), this.pos.currency.rounding); + var all_taxes_before_discount = this.compute_all(product_taxes, this.get_unit_price(), this.get_quantity(), this.pos.currency.rounding); + _(all_taxes.taxes).each(function (tax) { + taxtotal += tax.amount; + taxdetail[tax.id] = tax.amount; + }); - // Set the quantity of the line based on number of pack lots. - if(!this.product.to_weight){ - this.pack_lot_lines.set_quantity_by_lot(); - } - }, - set_product_lot: function(product){ - this.has_product_lot = product.tracking !== 'none'; - this.pack_lot_lines = this.has_product_lot && new PacklotlineCollection(null, {'order_line': this}); - }, - // sets a discount [0,100]% - set_discount: function(discount){ - var parsed_discount = typeof(discount) === 'number' ? discount : isNaN(parseFloat(discount)) ? 0 : field_utils.parse.float('' + discount); - var disc = Math.min(Math.max(parsed_discount || 0, 0),100); - this.discount = disc; - this.discountStr = '' + disc; - this.trigger('change',this); - }, - // returns the discount [0,100]% - get_discount: function(){ - return this.discount; - }, - get_discount_str: function(){ - return this.discountStr; - }, - set_description: function(description){ - this.description = description || ''; - }, - set_price_extra: function(price_extra){ - this.price_extra = parseFloat(price_extra) || 0.0; - }, - set_full_product_name: function(full_product_name){ - this.full_product_name = full_product_name || ''; - }, - get_price_extra: function () { - return this.price_extra; - }, - // 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, keep_price){ - this.order.assert_editable(); - if(quantity === 'remove'){ - this.order.remove_orderline(this); - return; - }else{ - var quant = typeof(quantity) === 'number' ? quantity : (field_utils.parse.float('' + quantity) || 0); - var unit = this.get_unit(); - if(unit){ - if (unit.rounding) { - var decimals = this.pos.dp['Product Unit of Measure']; - var rounding = Math.max(unit.rounding, Math.pow(10, -decimals)); - this.quantity = round_pr(quant, rounding); - this.quantityStr = field_utils.format.float(this.quantity, {digits: [69, decimals]}); - } else { - this.quantity = round_pr(quant, 1); - this.quantityStr = this.quantity.toFixed(0); + return { + "priceWithTax": all_taxes.total_included, + "priceWithoutTax": all_taxes.total_excluded, + "priceSumTaxVoid": all_taxes.total_void, + "priceWithTaxBeforeDiscount": all_taxes_before_discount.total_included, + "tax": taxtotal, + "taxDetails": taxdetail, + }; + }, + display_discount_policy: function () { + return this.order.pricelist.discount_policy; + }, + compute_fixed_price: function (price) { + var order = this.order; + if (order.fiscal_position) { + var taxes = this.get_taxes(); + var mapped_included_taxes = []; + var new_included_taxes = []; + var self = this; + _(taxes).each(function (tax) { + var line_taxes = self._map_tax_fiscal_position(tax, order); + if (line_taxes.length && line_taxes[0].price_include) { + new_included_taxes = new_included_taxes.concat(line_taxes); + } + if (tax.price_include && !_.contains(line_taxes, tax)) { + mapped_included_taxes.push(tax); + } + }); + + if (mapped_included_taxes.length > 0) { + if (new_included_taxes.length > 0) { + var price_without_taxes = this.compute_all(mapped_included_taxes, price, 1, order.pos.currency.rounding, true).total_excluded + return this.compute_all(new_included_taxes, price_without_taxes, 1, order.pos.currency.rounding, false).total_included + } + else { + return this.compute_all(mapped_included_taxes, price, 1, order.pos.currency.rounding, true).total_excluded; + } } - }else{ - this.quantity = quant; - this.quantityStr = '' + this.quantity; } - } + return price; + }, + get_fixed_lst_price: function () { + return this.compute_fixed_price(this.get_lst_price()); + }, + get_lst_price: function () { + return this.product.get_price(this.pos.default_pricelist, 1, 0) + }, + set_lst_price: function (price) { + this.order.assert_editable(); + this.product.lst_price = round_di(parseFloat(price) || 0, this.pos.dp['Product Price']); + this.trigger('change', this); + }, + is_last_line: function () { + var order = this.pos.get_order(); + var last_id = Object.keys(order.orderlines._byId)[Object.keys(order.orderlines._byId).length - 1]; + var selectedLine = order ? order.selected_orderline : null; - // just like in sale.order changing the quantity will recompute the unit price - if (!keep_price && !this.price_manually_set && !( - this.pos.config.product_configurator && _.some(this.product.attribute_line_ids, (id) => id in this.pos.attributes_by_ptal_id))){ - this.set_unit_price(this.product.get_price(this.order.pricelist, this.get_quantity(), this.get_price_extra())); - this.order.fix_tax_included_price(this); - } - this.trigger('change', this); - }, - // return the quantity of product - get_quantity: function(){ - return this.quantity; - }, - get_quantity_str: function(){ - return this.quantityStr; - }, - get_quantity_str_with_unit: function(){ - var unit = this.get_unit(); - if(unit && !unit.is_pos_groupable){ - return this.quantityStr + ' ' + unit.name; - }else{ - return this.quantityStr; - } - }, + return !selectedLine ? false : last_id === selectedLine.cid; + }, + }); - get_lot_lines: function() { - return this.pack_lot_lines.models; - }, + var OrderlineCollection = Backbone.Collection.extend({ + model: exports.Orderline, + }); - get_required_number_of_lots: function(){ - var lots_required = 1; + exports.Packlotline = Backbone.Model.extend({ + defaults: { + lot_name: null + }, + initialize: function (attributes, options) { + this.order_line = options.order_line; + if (options.json) { + this.init_from_JSON(options.json); + return; + } + }, - if (this.product.tracking == 'serial') { - lots_required = Math.abs(this.quantity); - } + init_from_JSON: function (json) { + this.order_line = json.order_line; + this.set_lot_name(json.lot_name); + }, - return lots_required; - }, + set_lot_name: function (name) { + this.set({ lot_name: _.str.trim(name) || null }); + }, - has_valid_product_lot: function(){ - if(!this.has_product_lot){ - return true; - } - var valid_product_lot = this.pack_lot_lines.get_valid_lots(); - return this.get_required_number_of_lots() === valid_product_lot.length; - }, - - // return the unit of measure of the product - get_unit: function(){ - return this.product.get_unit(); - }, - // return the product of this orderline - get_product: function(){ - return this.product; - }, - get_full_product_name: function () { - if (this.full_product_name) { - return this.full_product_name - } - var full_name = this.product.display_name; - if (this.description) { - full_name += ` (${this.description})`; - } - return full_name; - }, - // selects or deselects this orderline - set_selected: function(selected){ - this.selected = selected; - // this trigger also triggers the change event of the collection. - this.trigger('change',this); - this.trigger('new-orderline-selected'); - }, - // returns true if this orderline is selected - is_selected: function(){ - return this.selected; - }, - // when we add an new orderline we want to merge it with the last line to see reduce the number of items - // in the orderline. This returns true if it makes sense to merge the two - can_be_merged_with: function(orderline){ - var price = parseFloat(round_di(this.price || 0, this.pos.dp['Product Price']).toFixed(this.pos.dp['Product Price'])); - var order_line_price = orderline.get_product().get_price(orderline.order.pricelist, this.get_quantity()); - order_line_price = round_di(orderline.compute_fixed_price(order_line_price), this.pos.currency.decimals); - if( this.get_product().id !== orderline.get_product().id){ //only orderline of the same product can be merged - return false; - }else if(!this.get_unit() || !this.get_unit().is_pos_groupable){ - return false; - }else if(this.get_discount() > 0){ // we don't merge discounted orderlines - return false; - }else if(!utils.float_is_zero(price - order_line_price - orderline.get_price_extra(), - this.pos.currency.decimals)){ - return false; - }else if(this.product.tracking == 'lot' && (this.pos.picking_type.use_create_lots || this.pos.picking_type.use_existing_lots)) { - return false; - }else if (this.description !== orderline.description) { - return false; - }else{ - return true; - } - }, - merge: function(orderline){ - this.order.assert_editable(); - this.set_quantity(this.get_quantity() + orderline.get_quantity()); - }, - export_as_JSON: function() { - var pack_lot_ids = []; - if (this.has_product_lot){ - this.pack_lot_lines.each(_.bind( function(item) { - return pack_lot_ids.push([0, 0, item.export_as_JSON()]); - }, this)); - } - return { - qty: this.get_quantity(), - price_unit: this.get_unit_price(), - price_subtotal: this.get_price_without_tax(), - price_subtotal_incl: this.get_price_with_tax(), - discount: this.get_discount(), - product_id: this.get_product().id, - tax_ids: [[6, false, _.map(this.get_applicable_taxes(), function(tax){ return tax.id; })]], - id: this.id, - pack_lot_ids: pack_lot_ids, - description: this.description, - full_product_name: this.get_full_product_name(), - price_extra: this.get_price_extra(), - }; - }, - //used to create a json of the ticket, to be sent to the printer - export_for_printing: function(){ - return { - id: this.id, - quantity: this.get_quantity(), - unit_name: this.get_unit().name, - is_in_unit: this.get_unit().id == this.pos.uom_unit_id, - price: this.get_unit_display_price(), - discount: this.get_discount(), - product_name: this.get_product().display_name, - product_name_wrapped: this.generate_wrapped_product_name(), - price_lst: this.get_lst_price(), - display_discount_policy: this.display_discount_policy(), - price_display_one: this.get_display_price_one(), - price_display : this.get_display_price(), - price_with_tax : this.get_price_with_tax(), - price_without_tax: this.get_price_without_tax(), - price_with_tax_before_discount: this.get_price_with_tax_before_discount(), - tax: this.get_tax(), - product_description: this.get_product().description, - product_description_sale: this.get_product().description_sale, - pack_lot_lines: this.get_lot_lines() - }; - }, - generate_wrapped_product_name: function() { - var MAX_LENGTH = 24; // 40 * line ratio of .6 - var wrapped = []; - var name = this.get_full_product_name(); - var current_line = ""; - - while (name.length > 0) { - var space_index = name.indexOf(" "); - - if (space_index === -1) { - space_index = name.length; - } - - if (current_line.length + space_index > MAX_LENGTH) { - if (current_line.length) { - wrapped.push(current_line); - } - current_line = ""; - } - - current_line += name.slice(0, space_index + 1); - name = name.slice(space_index + 1); - } + get_lot_name: function () { + return this.get('lot_name'); + }, - if (current_line.length) { - wrapped.push(current_line); - } + export_as_JSON: function () { + return { + lot_name: this.get_lot_name(), + }; + }, - return wrapped; - }, - // changes the base price of the product for this orderline - set_unit_price: function(price){ - this.order.assert_editable(); - var parsed_price = !isNaN(price) ? - price : - isNaN(parseFloat(price)) ? 0 : field_utils.parse.float('' + price) - this.price = round_di(parsed_price || 0, this.pos.dp['Product Price']); - this.trigger('change',this); - }, - get_unit_price: function(){ - var digits = this.pos.dp['Product Price']; - // round and truncate to mimic _symbol_set behavior - return parseFloat(round_di(this.price || 0, digits).toFixed(digits)); - }, - get_unit_display_price: function(){ - if (this.pos.config.iface_tax_included === 'total') { - var quantity = this.quantity; - this.quantity = 1.0; - var price = this.get_all_prices().priceWithTax; - this.quantity = quantity; - return price; - } else { - return this.get_unit_price(); - } - }, - get_base_price: function(){ - var rounding = this.pos.currency.rounding; - return round_pr(this.get_unit_price() * this.get_quantity() * (1 - this.get_discount()/100), rounding); - }, - get_taxes_after_fp: function(taxes_ids){ - return this.pos.get_taxes_after_fp(taxes_ids, this.order); - }, - get_display_price_one: function(){ - var rounding = this.pos.currency.rounding; - var price_unit = this.get_unit_price(); - if (this.pos.config.iface_tax_included !== 'total') { - return round_pr(price_unit * (1.0 - (this.get_discount() / 100.0)), rounding); - } else { - var product = this.get_product(); - var taxes_ids = product.taxes_id; - var product_taxes = this.get_taxes_after_fp(taxes_ids); - var all_taxes = this.compute_all(product_taxes, price_unit, 1, this.pos.currency.rounding); + add: function () { + var order_line = this.order_line, + index = this.collection.indexOf(this); + var new_lot_model = new exports.Packlotline({}, { 'order_line': this.order_line }); + this.collection.add(new_lot_model, { at: index + 1 }); + return new_lot_model; + }, - return round_pr(all_taxes.total_included * (1 - this.get_discount()/100), rounding); - } - }, - get_display_price: function(){ - if (this.pos.config.iface_tax_included === 'total') { - return this.get_price_with_tax(); - } else { - return this.get_base_price(); - } - }, - get_taxed_lst_unit_price: function(){ - var lst_price = this.compute_fixed_price(this.get_lst_price()); - if (this.pos.config.iface_tax_included === 'total') { - var product = this.get_product(); - var taxes_ids = product.taxes_id; - var product_taxes = this.get_taxes_after_fp(taxes_ids); - return this.compute_all(product_taxes, lst_price, 1, this.pos.currency.rounding).total_included; - } - return lst_price; - }, - get_price_without_tax: function(){ - return this.get_all_prices().priceWithoutTax; - }, - get_price_with_tax: function(){ - return this.get_all_prices().priceWithTax; - }, - get_price_with_tax_before_discount: function () { - return this.get_all_prices().priceWithTaxBeforeDiscount; - }, - get_tax: function(){ - return this.get_all_prices().tax; - }, - get_applicable_taxes: function(){ - var i; - // Shenaningans because we need - // to keep the taxes ordering. - var ptaxes_ids = this.get_product().taxes_id; - var ptaxes_set = {}; - for (i = 0; i < ptaxes_ids.length; i++) { - ptaxes_set[ptaxes_ids[i]] = true; - } - var taxes = []; - for (i = 0; i < this.pos.taxes.length; i++) { - if (ptaxes_set[this.pos.taxes[i].id]) { - taxes.push(this.pos.taxes[i]); - } - } - return taxes; - }, - get_tax_details: function(){ - return this.get_all_prices().taxDetails; - }, - get_taxes: function(){ - var taxes_ids = this.get_product().taxes_id; - var taxes = []; - for (var i = 0; i < taxes_ids.length; i++) { - if (this.pos.taxes_by_id[taxes_ids[i]]) { - taxes.push(this.pos.taxes_by_id[taxes_ids[i]]); - } + remove: function () { + this.collection.remove(this); } - return taxes; - }, - _map_tax_fiscal_position: function(tax, order = false) { - return this.pos._map_tax_fiscal_position(tax, order); - }, - /** - * Mirror JS method of: - * _compute_amount in addons/account/models/account.py - */ - _compute_all: function(tax, base_amount, quantity, price_exclude) { - return this.pos._compute_all(tax, base_amount, quantity, price_exclude) - }, - /** - * Mirror JS method of: - * compute_all in addons/account/models/account.py - * - * Read comments in the python side method for more details about each sub-methods. - */ - compute_all: function(taxes, price_unit, quantity, currency_rounding, handle_price_include=true) { - return this.pos.compute_all(taxes, price_unit, quantity, currency_rounding, handle_price_include) - - }, - get_all_prices: function(){ - - var price_unit = this.get_unit_price() * (1.0 - (this.get_discount() / 100.0)); - var taxtotal = 0; - - var product = this.get_product(); - var taxes_ids = _.filter(product.taxes_id, t => t in this.pos.taxes_by_id); - var taxdetail = {}; - var product_taxes = this.get_taxes_after_fp(taxes_ids); - - var all_taxes = this.compute_all(product_taxes, price_unit, this.get_quantity(), this.pos.currency.rounding); - var all_taxes_before_discount = this.compute_all(product_taxes, this.get_unit_price(), this.get_quantity(), this.pos.currency.rounding); - _(all_taxes.taxes).each(function(tax) { - taxtotal += tax.amount; - taxdetail[tax.id] = tax.amount; - }); - - return { - "priceWithTax": all_taxes.total_included, - "priceWithoutTax": all_taxes.total_excluded, - "priceSumTaxVoid": all_taxes.total_void, - "priceWithTaxBeforeDiscount": all_taxes_before_discount.total_included, - "tax": taxtotal, - "taxDetails": taxdetail, - }; - }, - display_discount_policy: function(){ - return this.order.pricelist.discount_policy; - }, - compute_fixed_price: function (price) { - var order = this.order; - if(order.fiscal_position) { - var taxes = this.get_taxes(); - var mapped_included_taxes = []; - var new_included_taxes = []; - var self = this; - _(taxes).each(function(tax) { - var line_taxes = self._map_tax_fiscal_position(tax, order); - if (line_taxes.length && line_taxes[0].price_include){ - new_included_taxes = new_included_taxes.concat(line_taxes); - } - if(tax.price_include && !_.contains(line_taxes, tax)){ - mapped_included_taxes.push(tax); - } + }); + + var PacklotlineCollection = Backbone.Collection.extend({ + model: exports.Packlotline, + initialize: function (models, options) { + this.order_line = options.order_line; + }, + + get_valid_lots: function () { + return this.filter(function (model) { + return model.get('lot_name'); }); + }, - if (mapped_included_taxes.length > 0) { - if (new_included_taxes.length > 0) { - var price_without_taxes = this.compute_all(mapped_included_taxes, price, 1, order.pos.currency.rounding, true).total_excluded - return this.compute_all(new_included_taxes, price_without_taxes, 1, order.pos.currency.rounding, false).total_included - } - else{ - return this.compute_all(mapped_included_taxes, price, 1, order.pos.currency.rounding, true).total_excluded; - } + set_quantity_by_lot: function () { + var valid_lots_quantity = this.get_valid_lots().length; + if (this.order_line.quantity < 0) { + valid_lots_quantity = -valid_lots_quantity; + } + this.order_line.set_quantity(valid_lots_quantity); + } + }); + + // Every Paymentline contains a cashregister and an amount of money. + exports.Paymentline = Backbone.Model.extend({ + initialize: function (attributes, options) { + this.pos = options.pos; + this.order = options.order; + this.amount = 0; + this.selected = false; + this.cashier_receipt = ''; + this.ticket = ''; + this.payment_status = ''; + this.card_type = ''; + this.cardholder_name = ''; + this.transaction_id = ''; + + if (options.json) { + this.init_from_JSON(options.json); + return; } - } - return price; - }, - get_fixed_lst_price: function(){ - return this.compute_fixed_price(this.get_lst_price()); - }, - get_lst_price: function(){ - return this.product.get_price(this.pos.default_pricelist, 1, 0) - }, - set_lst_price: function(price){ - this.order.assert_editable(); - this.product.lst_price = round_di(parseFloat(price) || 0, this.pos.dp['Product Price']); - this.trigger('change',this); - }, - is_last_line: function() { - var order = this.pos.get_order(); - var last_id = Object.keys(order.orderlines._byId)[Object.keys(order.orderlines._byId).length-1]; - var selectedLine = order? order.selected_orderline: null; - - return !selectedLine ? false : last_id === selectedLine.cid; - }, -}); + this.payment_method = options.payment_method; + if (this.payment_method === undefined) { + throw new Error(_t('Please configure a payment method in your POS.')); + } + this.name = this.payment_method.name; + }, + init_from_JSON: function (json) { + this.amount = json.amount; + this.payment_method = this.pos.payment_methods_by_id[json.payment_method_id]; + this.can_be_reversed = json.can_be_reversed; + this.name = this.payment_method.name; + this.payment_status = json.payment_status; + this.ticket = json.ticket; + this.card_type = json.card_type; + this.cardholder_name = json.cardholder_name; + this.transaction_id = json.transaction_id; + this.is_change = json.is_change; + }, + //sets the amount of money on this payment line + set_amount: function (value) { + this.order.assert_editable(); + this.amount = round_di(parseFloat(value) || 0, this.pos.currency.decimals); + if (this.pos.config.iface_customer_facing_display) this.pos.send_current_order_to_customer_facing_display(); + this.trigger('change', this); + }, + // returns the amount of money on this paymentline + get_amount: function () { + return this.amount; + }, + get_amount_str: function () { + return field_utils.format.float(this.amount, { digits: [69, this.pos.currency.decimals] }); + }, + set_selected: function (selected) { + if (this.selected !== selected) { + this.selected = selected; + this.trigger('change', this); + } + }, + /** + * returns {string} payment status. + */ + get_payment_status: function () { + return this.payment_status; + }, -var OrderlineCollection = Backbone.Collection.extend({ - model: exports.Orderline, -}); + /** + * Set the new payment status. + * + * @param {string} value - new status. + */ + set_payment_status: function (value) { + this.payment_status = value; + this.trigger('change', this); + }, -exports.Packlotline = Backbone.Model.extend({ - defaults: { - lot_name: null - }, - initialize: function(attributes, options){ - this.order_line = options.order_line; - if (options.json) { - this.init_from_JSON(options.json); - return; - } - }, - - init_from_JSON: function(json) { - this.order_line = json.order_line; - this.set_lot_name(json.lot_name); - }, - - set_lot_name: function(name){ - this.set({lot_name : _.str.trim(name) || null}); - }, - - get_lot_name: function(){ - return this.get('lot_name'); - }, - - export_as_JSON: function(){ - return { - lot_name: this.get_lot_name(), - }; - }, - - add: function(){ - var order_line = this.order_line, - index = this.collection.indexOf(this); - var new_lot_model = new exports.Packlotline({}, {'order_line': this.order_line}); - this.collection.add(new_lot_model, {at: index + 1}); - return new_lot_model; - }, - - remove: function(){ - this.collection.remove(this); - } -}); + /** + * Check if paymentline is done. + * Paymentline is done if there is no payment status or the payment status is done. + */ + is_done: function () { + return this.get_payment_status() ? this.get_payment_status() === 'done' || this.get_payment_status() === 'reversed' : true; + }, -var PacklotlineCollection = Backbone.Collection.extend({ - model: exports.Packlotline, - initialize: function(models, options) { - this.order_line = options.order_line; - }, - - get_valid_lots: function(){ - return this.filter(function(model){ - return model.get('lot_name'); - }); - }, - - set_quantity_by_lot: function() { - var valid_lots_quantity = this.get_valid_lots().length; - if (this.order_line.quantity < 0){ - valid_lots_quantity = -valid_lots_quantity; - } - this.order_line.set_quantity(valid_lots_quantity); - } -}); + /** + * Set info to be printed on the cashier receipt. value should + * be compatible with both the QWeb and ESC/POS receipts. + * + * @param {string} value - receipt info + */ + set_cashier_receipt: function (value) { + this.cashier_receipt = value; + this.trigger('change', this); + }, -// Every Paymentline contains a cashregister and an amount of money. -exports.Paymentline = Backbone.Model.extend({ - initialize: function(attributes, options) { - this.pos = options.pos; - this.order = options.order; - this.amount = 0; - this.selected = false; - this.cashier_receipt = ''; - this.ticket = ''; - this.payment_status = ''; - this.card_type = ''; - this.cardholder_name = ''; - this.transaction_id = ''; - - if (options.json) { - this.init_from_JSON(options.json); - return; - } - this.payment_method = options.payment_method; - if (this.payment_method === undefined) { - throw new Error(_t('Please configure a payment method in your POS.')); - } - this.name = this.payment_method.name; - }, - init_from_JSON: function(json){ - this.amount = json.amount; - this.payment_method = this.pos.payment_methods_by_id[json.payment_method_id]; - this.can_be_reversed = json.can_be_reversed; - this.name = this.payment_method.name; - this.payment_status = json.payment_status; - this.ticket = json.ticket; - this.card_type = json.card_type; - this.cardholder_name = json.cardholder_name; - this.transaction_id = json.transaction_id; - this.is_change = json.is_change; - }, - //sets the amount of money on this payment line - set_amount: function(value){ - this.order.assert_editable(); - this.amount = round_di(parseFloat(value) || 0, this.pos.currency.decimals); - if (this.pos.config.iface_customer_facing_display) this.pos.send_current_order_to_customer_facing_display(); - this.trigger('change',this); - }, - // returns the amount of money on this paymentline - get_amount: function(){ - return this.amount; - }, - get_amount_str: function(){ - return field_utils.format.float(this.amount, {digits: [69, this.pos.currency.decimals]}); - }, - set_selected: function(selected){ - if(this.selected !== selected){ - this.selected = selected; - this.trigger('change',this); - } - }, - /** - * returns {string} payment status. - */ - get_payment_status: function() { - return this.payment_status; - }, + /** + * Set additional info to be printed on the receipts. value should + * be compatible with both the QWeb and ESC/POS receipts. + * + * @param {string} value - receipt info + */ + set_receipt_info: function (value) { + this.ticket += value; + this.trigger('change', this); + }, - /** - * Set the new payment status. - * - * @param {string} value - new status. - */ - set_payment_status: function(value) { - this.payment_status = value; - this.trigger('change', this); - }, + // returns the associated cashregister + //exports as JSON for server communication + export_as_JSON: function () { + return { + name: time.datetime_to_str(new Date()), + payment_method_id: this.payment_method.id, + amount: this.get_amount(), + payment_status: this.payment_status, + can_be_reversed: this.can_be_resersed, + ticket: this.ticket, + card_type: this.card_type, + cardholder_name: this.cardholder_name, + transaction_id: this.transaction_id, + }; + }, + //exports as JSON for receipt printing + export_for_printing: function () { + return { + cid: this.cid, + amount: this.get_amount(), + name: this.name, + ticket: this.ticket, + }; + }, + // If payment status is a non-empty string, then it is an electronic payment. + // TODO: There has to be a less confusing way to distinguish simple payments + // from electronic transactions. Perhaps use a flag? + is_electronic: function () { + return Boolean(this.get_payment_status()); + }, + }); + + var PaymentlineCollection = Backbone.Collection.extend({ + model: exports.Paymentline, + }); + + // An order more or less represents the content of a client's shopping cart (the OrderLines) + // plus the associated payment information (the Paymentlines) + // there is always an active ('selected') order in the Pos, a new one is created + // automaticaly once an order is completed and sent to the server. + exports.Order = Backbone.Model.extend({ + initialize: function (attributes, options) { + Backbone.Model.prototype.initialize.apply(this, arguments); + var self = this; + options = options || {}; - /** - * Check if paymentline is done. - * Paymentline is done if there is no payment status or the payment status is done. - */ - is_done: function() { - return this.get_payment_status() ? this.get_payment_status() === 'done' || this.get_payment_status() === 'reversed': true; - }, + this.locked = false; + this.pos = options.pos; + this.selected_orderline = undefined; + this.selected_paymentline = undefined; + this.screen_data = {}; // see Gui + this.temporary = options.temporary || false; + this.creation_date = new Date(); + this.to_invoice = false; + this.orderlines = new OrderlineCollection(); + this.paymentlines = new PaymentlineCollection(); + this.pos_session_id = this.pos.pos_session.id; + this.employee = this.pos.employee; + this.finalized = false; // if true, cannot be modified. + this.set_pricelist(this.pos.default_pricelist); + + this.set({ client: null }); + + this.uiState = { + ReceiptScreen: new Context({ + inputEmail: '', + // if null: not yet tried to send + // if false/true: tried sending email + emailSuccessful: null, + emailNotice: '', + }), + TipScreen: new Context({ + inputTipAmount: '', + }) + }; - /** - * Set info to be printed on the cashier receipt. value should - * be compatible with both the QWeb and ESC/POS receipts. - * - * @param {string} value - receipt info - */ - set_cashier_receipt: function (value) { - this.cashier_receipt = value; - this.trigger('change', this); - }, + if (options.json) { + this.init_from_JSON(options.json); + } else { + this.sequence_number = this.pos.pos_session.sequence_number++; + this.uid = this.generate_unique_id(); + this.name = _.str.sprintf(_t("Order %s"), this.uid); + this.validation_date = undefined; + this.fiscal_position = _.find(this.pos.fiscal_positions, function (fp) { + return fp.id === self.pos.config.default_fiscal_position_id[0]; + }); + } - /** - * Set additional info to be printed on the receipts. value should - * be compatible with both the QWeb and ESC/POS receipts. - * - * @param {string} value - receipt info - */ - set_receipt_info: function(value) { - this.ticket += value; - this.trigger('change', this); - }, - - // returns the associated cashregister - //exports as JSON for server communication - export_as_JSON: function(){ - return { - name: time.datetime_to_str(new Date()), - payment_method_id: this.payment_method.id, - amount: this.get_amount(), - payment_status: this.payment_status, - can_be_reversed: this.can_be_resersed, - ticket: this.ticket, - card_type: this.card_type, - cardholder_name: this.cardholder_name, - transaction_id: this.transaction_id, - }; - }, - //exports as JSON for receipt printing - export_for_printing: function(){ - return { - cid: this.cid, - amount: this.get_amount(), - name: this.name, - ticket: this.ticket, - }; - }, - // If payment status is a non-empty string, then it is an electronic payment. - // TODO: There has to be a less confusing way to distinguish simple payments - // from electronic transactions. Perhaps use a flag? - is_electronic: function() { - return Boolean(this.get_payment_status()); - }, -}); + this.on('change', function () { this.save_to_db("order:change"); }, this); + this.orderlines.on('change', function () { this.save_to_db("orderline:change"); }, this); + this.orderlines.on('add', function () { this.save_to_db("orderline:add"); }, this); + this.orderlines.on('remove', function () { this.save_to_db("orderline:remove"); }, this); + this.paymentlines.on('change', function () { this.save_to_db("paymentline:change"); }, this); + this.paymentlines.on('add', function () { this.save_to_db("paymentline:add"); }, this); + this.paymentlines.on('remove', function () { this.save_to_db("paymentline:rem"); }, this); -var PaymentlineCollection = Backbone.Collection.extend({ - model: exports.Paymentline, -}); + if (this.pos.config.iface_customer_facing_display) { + this.paymentlines.on('add', this.pos.send_current_order_to_customer_facing_display, this.pos); + this.paymentlines.on('remove', this.pos.send_current_order_to_customer_facing_display, this.pos); + } -// An order more or less represents the content of a client's shopping cart (the OrderLines) -// plus the associated payment information (the Paymentlines) -// there is always an active ('selected') order in the Pos, a new one is created -// automaticaly once an order is completed and sent to the server. -exports.Order = Backbone.Model.extend({ - initialize: function(attributes,options){ - Backbone.Model.prototype.initialize.apply(this, arguments); - var self = this; - options = options || {}; - - this.locked = false; - this.pos = options.pos; - this.selected_orderline = undefined; - this.selected_paymentline = undefined; - this.screen_data = {}; // see Gui - this.temporary = options.temporary || false; - this.creation_date = new Date(); - this.to_invoice = false; - this.orderlines = new OrderlineCollection(); - this.paymentlines = new PaymentlineCollection(); - this.pos_session_id = this.pos.pos_session.id; - this.employee = this.pos.employee; - this.finalized = false; // if true, cannot be modified. - this.set_pricelist(this.pos.default_pricelist); - - this.set({ client: null }); - - this.uiState = { - ReceiptScreen: new Context({ - inputEmail: '', - // if null: not yet tried to send - // if false/true: tried sending email - emailSuccessful: null, - emailNotice: '', - }), - TipScreen: new Context({ - inputTipAmount: '', - }) - }; + this.save_to_db(); - if (options.json) { - this.init_from_JSON(options.json); - } else { - this.sequence_number = this.pos.pos_session.sequence_number++; - this.uid = this.generate_unique_id(); + return this; + }, + save_to_db: function () { + if (!this.temporary && !this.locked) { + this.assert_editable(); + this.pos.db.save_unpaid_order(this); + } + }, + /** + * Initialize PoS order from a JSON string. + * + * If the order was created in another session, the sequence number should be changed so it doesn't conflict + * with orders in the current session. + * Else, the sequence number of the session should follow on the sequence number of the loaded order. + * + * @param {object} json JSON representing one PoS order. + */ + init_from_JSON: function (json) { + var client; + if (json.pos_session_id !== this.pos.pos_session.id) { + this.sequence_number = this.pos.pos_session.sequence_number++; + } else { + 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 = this.pos.pos_session.id; + this.uid = json.uid; this.name = _.str.sprintf(_t("Order %s"), this.uid); - this.validation_date = undefined; - this.fiscal_position = _.find(this.pos.fiscal_positions, function(fp) { - return fp.id === self.pos.config.default_fiscal_position_id[0]; - }); - } + this.validation_date = json.creation_date; + this.server_id = json.server_id ? json.server_id : false; + this.user_id = json.user_id; - this.on('change', function(){ this.save_to_db("order:change"); }, this); - this.orderlines.on('change', function(){ this.save_to_db("orderline:change"); }, this); - this.orderlines.on('add', function(){ this.save_to_db("orderline:add"); }, this); - this.orderlines.on('remove', function(){ this.save_to_db("orderline:remove"); }, this); - this.paymentlines.on('change', function(){ this.save_to_db("paymentline:change"); }, this); - this.paymentlines.on('add', function(){ this.save_to_db("paymentline:add"); }, this); - this.paymentlines.on('remove', function(){ this.save_to_db("paymentline:rem"); }, this); - - if (this.pos.config.iface_customer_facing_display) { - this.paymentlines.on('add', this.pos.send_current_order_to_customer_facing_display, this.pos); - this.paymentlines.on('remove', this.pos.send_current_order_to_customer_facing_display, this.pos); - } + if (json.fiscal_position_id) { + var fiscal_position = _.find(this.pos.fiscal_positions, function (fp) { + return fp.id === json.fiscal_position_id; + }); - this.save_to_db(); + if (fiscal_position) { + this.fiscal_position = fiscal_position; + } else { + console.error('ERROR: trying to load a fiscal position not available in the pos'); + } + } - return this; - }, - save_to_db: function(){ - if (!this.temporary && !this.locked) { - this.assert_editable(); - this.pos.db.save_unpaid_order(this); - } - }, - /** - * Initialize PoS order from a JSON string. - * - * If the order was created in another session, the sequence number should be changed so it doesn't conflict - * with orders in the current session. - * Else, the sequence number of the session should follow on the sequence number of the loaded order. - * - * @param {object} json JSON representing one PoS order. - */ - init_from_JSON: function(json) { - var client; - if (json.pos_session_id !== this.pos.pos_session.id) { - this.sequence_number = this.pos.pos_session.sequence_number++; - } else { - 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 = this.pos.pos_session.id; - this.uid = json.uid; - this.name = _.str.sprintf(_t("Order %s"), 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) { - return fp.id === json.fiscal_position_id; - }); + 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 (fiscal_position) { - this.fiscal_position = fiscal_position; + if (json.partner_id) { + client = this.pos.db.get_partner_by_id(json.partner_id); + if (!client) { + console.error('ERROR: trying to load a partner not available in the pos'); + } } else { - console.error('ERROR: trying to load a fiscal position not available in the pos'); + client = null; } - } + this.set_client(client); - 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; - } + this.temporary = false; // FIXME + this.to_invoice = false; // FIXME - if (json.partner_id) { - client = this.pos.db.get_partner_by_id(json.partner_id); - if (!client) { - console.error('ERROR: trying to load a partner not available in the pos'); + var orderlines = json.lines; + for (var i = 0; i < orderlines.length; i++) { + var orderline = orderlines[i][2]; + if (this.pos.db.get_product_by_id(orderline.product_id)) { + this.add_orderline(new exports.Orderline({}, { pos: this.pos, order: this, json: orderline })); + } } - } else { - client = null; - } - this.set_client(client); - this.temporary = false; // FIXME - this.to_invoice = false; // FIXME + var paymentlines = json.statement_ids; + for (var i = 0; i < paymentlines.length; i++) { + var paymentline = paymentlines[i][2]; + var newpaymentline = new exports.Paymentline({}, { pos: this.pos, order: this, json: paymentline }); + this.paymentlines.add(newpaymentline); - var orderlines = json.lines; - for (var i = 0; i < orderlines.length; i++) { - var orderline = orderlines[i][2]; - if(this.pos.db.get_product_by_id(orderline.product_id)){ - this.add_orderline(new exports.Orderline({}, {pos: this.pos, order: this, json: orderline})); + if (i === paymentlines.length - 1) { + this.select_paymentline(newpaymentline); + } } - } - - var paymentlines = json.statement_ids; - for (var i = 0; i < paymentlines.length; i++) { - var paymentline = paymentlines[i][2]; - var newpaymentline = new exports.Paymentline({},{pos: this.pos, order: this, json: paymentline}); - this.paymentlines.add(newpaymentline); - if (i === paymentlines.length - 1) { - this.select_paymentline(newpaymentline); + // Tag this order as 'locked' if it is already paid. + this.locked = ['paid', 'done', 'invoiced'].includes(json.state); + this.state = json.state; + this.amount_return = json.amount_return; + this.account_move = json.account_move; + this.backendId = json.id; + this.isFromClosedSession = json.is_session_closed; + this.is_tipped = json.is_tipped || false; + this.tip_amount = json.tip_amount || 0; + }, + export_as_JSON: function () { + var orderLines, paymentLines; + orderLines = []; + this.orderlines.each(_.bind(function (item) { + return orderLines.push([0, 0, item.export_as_JSON()]); + }, this)); + paymentLines = []; + this.paymentlines.each(_.bind(function (item) { + return paymentLines.push([0, 0, item.export_as_JSON()]); + }, this)); + var json = { + name: this.get_name(), + amount_paid: this.get_total_paid() - this.get_change(), + amount_total: this.get_total_with_tax(), + amount_tax: this.get_total_tax(), + amount_return: this.get_change(), + 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.user.id, + 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, + server_id: this.server_id ? this.server_id : false, + to_invoice: this.to_invoice ? this.to_invoice : false, + is_tipped: this.is_tipped || false, + tip_amount: this.tip_amount || 0, + }; + if (!this.is_paid && this.user_id) { + json.user_id = this.user_id; } - } + return json; + }, + export_for_printing: function () { + var orderlines = []; + var self = this; - // Tag this order as 'locked' if it is already paid. - this.locked = ['paid', 'done', 'invoiced'].includes(json.state); - this.state = json.state; - this.amount_return = json.amount_return; - this.account_move = json.account_move; - this.backendId = json.id; - this.isFromClosedSession = json.is_session_closed; - this.is_tipped = json.is_tipped || false; - this.tip_amount = json.tip_amount || 0; - }, - export_as_JSON: function() { - var orderLines, paymentLines; - orderLines = []; - this.orderlines.each(_.bind( function(item) { - return orderLines.push([0, 0, item.export_as_JSON()]); - }, this)); - paymentLines = []; - this.paymentlines.each(_.bind( function(item) { - return paymentLines.push([0, 0, item.export_as_JSON()]); - }, this)); - var json = { - name: this.get_name(), - amount_paid: this.get_total_paid() - this.get_change(), - amount_total: this.get_total_with_tax(), - amount_tax: this.get_total_tax(), - amount_return: this.get_change(), - 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.user.id, - 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, - server_id: this.server_id ? this.server_id : false, - to_invoice: this.to_invoice ? this.to_invoice : false, - is_tipped: this.is_tipped || false, - tip_amount: this.tip_amount || 0, - }; - if (!this.is_paid && this.user_id) { - json.user_id = this.user_id; - } - return json; - }, - export_for_printing: function(){ - var orderlines = []; - var self = this; - - this.orderlines.each(function(orderline){ - orderlines.push(orderline.export_for_printing()); - }); - - // If order is locked (paid), the 'change' is saved as negative payment, - // and is flagged with is_change = true. A receipt that is printed first - // time doesn't show this negative payment so we filter it out. - var paymentlines = this.paymentlines.models - .filter(function (paymentline) { - return !paymentline.is_change; - }) - .map(function (paymentline) { - return paymentline.export_for_printing(); + this.orderlines.each(function (orderline) { + orderlines.push(orderline.export_for_printing()); }); - var client = this.get('client'); - var cashier = this.pos.get_cashier(); - var company = this.pos.company; - var date = new Date(); - function is_html(subreceipt){ - return subreceipt ? (subreceipt.split('\n')[0].indexOf('= 0) : false; - } + // If order is locked (paid), the 'change' is saved as negative payment, + // and is flagged with is_change = true. A receipt that is printed first + // time doesn't show this negative payment so we filter it out. + var paymentlines = this.paymentlines.models + .filter(function (paymentline) { + return !paymentline.is_change; + }) + .map(function (paymentline) { + return paymentline.export_for_printing(); + }); + var client = this.get('client'); + var cashier = this.pos.get_cashier(); + var company = this.pos.company; + var date = new Date(); + + function is_html(subreceipt) { + return subreceipt ? (subreceipt.split('\n')[0].indexOf('= 0) : false; + } + + function render_html(subreceipt) { + if (!is_html(subreceipt)) { + return subreceipt; + } else { + subreceipt = subreceipt.split('\n').slice(1).join('\n'); + var qweb = new QWeb2.Engine(); + qweb.debug = config.isDebug(); + qweb.default_dict = _.clone(QWeb.default_dict); + qweb.add_template('' + subreceipt + ''); + + return qweb.render('subreceipt', { 'pos': self.pos, 'order': self, 'receipt': receipt }); + } + } + + var receipt = { + orderlines: orderlines, + paymentlines: paymentlines, + subtotal: this.get_subtotal(), + total_with_tax: this.get_total_with_tax(), + total_rounded: this.get_total_with_tax() + this.get_rounding_applied(), + total_without_tax: this.get_total_without_tax(), + total_tax: this.get_total_tax(), + total_paid: this.get_total_paid(), + total_discount: this.get_total_discount(), + rounding_applied: this.get_rounding_applied(), + tax_details: this.get_tax_details(), + change: this.locked ? this.amount_return : this.get_change(), + name: this.get_name(), + client: client ? client : null, + invoice_id: null, //TODO + cashier: cashier ? cashier.name : null, + precision: { + price: 2, + money: 2, + quantity: 3, + }, + date: { + year: date.getFullYear(), + month: date.getMonth(), + date: date.getDate(), // day of the month + day: date.getDay(), // day of the week + hour: date.getHours(), + minute: date.getMinutes(), + isostring: date.toISOString(), + localestring: this.formatted_validation_date, + }, + company: { + email: company.email, + website: company.website, + company_registry: company.company_registry, + contact_address: company.partner_id[1], + vat: company.vat, + vat_label: company.country && company.country.vat_label || _t('Tax ID'), + name: company.name, + phone: company.phone, + logo: this.pos.company_logo_base64, + }, + currency: this.pos.currency, + }; + + if (is_html(this.pos.config.receipt_header)) { + receipt.header = ''; + receipt.header_html = render_html(this.pos.config.receipt_header); + } else { + receipt.header = this.pos.config.receipt_header || ''; + } + + if (is_html(this.pos.config.receipt_footer)) { + receipt.footer = ''; + receipt.footer_html = render_html(this.pos.config.receipt_footer); + } else { + receipt.footer = this.pos.config.receipt_footer || ''; + } - function render_html(subreceipt){ - if (!is_html(subreceipt)) { - return subreceipt; + return receipt; + }, + is_empty: function () { + return this.orderlines.models.length === 0; + }, + generate_unique_id: function () { + // Generates a public identification number for the order. + // The generated number must be unique and sequential. They are made 12 digit long + // to fit into EAN-13 barcodes, should it be needed + + function zero_pad(num, size) { + var s = "" + num; + while (s.length < size) { + s = "0" + s; + } + return s; + } + return zero_pad(this.pos.pos_session.id, 5) + '-' + + zero_pad(this.pos.pos_session.login_number, 3) + '-' + + zero_pad(this.sequence_number, 4); + }, + get_name: function () { + return this.name; + }, + assert_editable: function () { + if (this.finalized) { + throw new Error('Finalized Order cannot be modified'); + } + }, + /* ---- Order Lines --- */ + add_orderline: function (line) { + this.assert_editable(); + if (line.order) { + line.order.remove_orderline(line); + } + line.order = this; + this.orderlines.add(line); + this.select_orderline(this.get_last_orderline()); + }, + get_orderline: function (id) { + var orderlines = this.orderlines.models; + for (var i = 0; i < orderlines.length; i++) { + if (orderlines[i].id === id) { + return orderlines[i]; + } + } + return null; + }, + get_orderlines: function () { + return this.orderlines.models; + }, + get_last_orderline: function () { + return this.orderlines.at(this.orderlines.length - 1); + }, + get_tip: function () { + var tip_product = this.pos.db.get_product_by_id(this.pos.config.tip_product_id[0]); + var lines = this.get_orderlines(); + if (!tip_product) { + return 0; } else { - subreceipt = subreceipt.split('\n').slice(1).join('\n'); - var qweb = new QWeb2.Engine(); - qweb.debug = config.isDebug(); - qweb.default_dict = _.clone(QWeb.default_dict); - qweb.add_template(''+subreceipt+''); - - return qweb.render('subreceipt',{'pos':self.pos,'order':self, 'receipt': receipt}) ; + for (var i = 0; i < lines.length; i++) { + if (lines[i].get_product() === tip_product) { + return lines[i].get_unit_price(); + } + } + return 0; } - } - - var receipt = { - orderlines: orderlines, - paymentlines: paymentlines, - subtotal: this.get_subtotal(), - total_with_tax: this.get_total_with_tax(), - total_rounded: this.get_total_with_tax() + this.get_rounding_applied(), - total_without_tax: this.get_total_without_tax(), - total_tax: this.get_total_tax(), - total_paid: this.get_total_paid(), - total_discount: this.get_total_discount(), - rounding_applied: this.get_rounding_applied(), - tax_details: this.get_tax_details(), - change: this.locked ? this.amount_return : this.get_change(), - name : this.get_name(), - client: client ? client : null , - invoice_id: null, //TODO - cashier: cashier ? cashier.name : null, - precision: { - price: 2, - money: 2, - quantity: 3, - }, - date: { - year: date.getFullYear(), - month: date.getMonth(), - date: date.getDate(), // day of the month - day: date.getDay(), // day of the week - hour: date.getHours(), - minute: date.getMinutes() , - isostring: date.toISOString(), - localestring: this.formatted_validation_date, - }, - company:{ - email: company.email, - website: company.website, - company_registry: company.company_registry, - contact_address: company.partner_id[1], - vat: company.vat, - vat_label: company.country && company.country.vat_label || _t('Tax ID'), - name: company.name, - phone: company.phone, - logo: this.pos.company_logo_base64, - }, - currency: this.pos.currency, - }; - - if (is_html(this.pos.config.receipt_header)){ - receipt.header = ''; - receipt.header_html = render_html(this.pos.config.receipt_header); - } else { - receipt.header = this.pos.config.receipt_header || ''; - } + }, - if (is_html(this.pos.config.receipt_footer)){ - receipt.footer = ''; - receipt.footer_html = render_html(this.pos.config.receipt_footer); - } else { - receipt.footer = this.pos.config.receipt_footer || ''; - } + initialize_validation_date: function () { + this.validation_date = new Date(); + this.formatted_validation_date = field_utils.format.datetime( + moment(this.validation_date), {}, { timezone: false }); + }, - return receipt; - }, - is_empty: function(){ - return this.orderlines.models.length === 0; - }, - generate_unique_id: function() { - // Generates a public identification number for the order. - // The generated number must be unique and sequential. They are made 12 digit long - // to fit into EAN-13 barcodes, should it be needed - - function zero_pad(num,size){ - var s = ""+num; - while (s.length < size) { - s = "0" + s; - } - return s; - } - return zero_pad(this.pos.pos_session.id,5) +'-'+ - zero_pad(this.pos.pos_session.login_number,3) +'-'+ - zero_pad(this.sequence_number,4); - }, - get_name: function() { - return this.name; - }, - assert_editable: function() { - if (this.finalized) { - throw new Error('Finalized Order cannot be modified'); - } - }, - /* ---- Order Lines --- */ - add_orderline: function(line){ - this.assert_editable(); - if(line.order){ - line.order.remove_orderline(line); - } - line.order = this; - this.orderlines.add(line); - this.select_orderline(this.get_last_orderline()); - }, - get_orderline: function(id){ - var orderlines = this.orderlines.models; - for(var i = 0; i < orderlines.length; i++){ - if(orderlines[i].id === id){ - return orderlines[i]; - } - } - return null; - }, - get_orderlines: function(){ - return this.orderlines.models; - }, - get_last_orderline: function(){ - return this.orderlines.at(this.orderlines.length -1); - }, - get_tip: function() { - var tip_product = this.pos.db.get_product_by_id(this.pos.config.tip_product_id[0]); - var lines = this.get_orderlines(); - if (!tip_product) { - return 0; - } else { - for (var i = 0; i < lines.length; i++) { - if (lines[i].get_product() === tip_product) { - return lines[i].get_unit_price(); + set_tip: function (tip) { + var tip_product = this.pos.db.get_product_by_id(this.pos.config.tip_product_id[0]); + var lines = this.get_orderlines(); + if (tip_product) { + for (var i = 0; i < lines.length; i++) { + if (lines[i].get_product() === tip_product) { + lines[i].set_unit_price(tip); + lines[i].set_lst_price(tip); + lines[i].price_manually_set = true; + lines[i].order.tip_amount = tip; + return; + } } + return this.add_product(tip_product, { + is_tip: true, + quantity: 1, + price: tip, + lst_price: tip, + extras: { price_manually_set: true }, + }); } - return 0; - } - }, - - initialize_validation_date: function () { - this.validation_date = new Date(); - this.formatted_validation_date = field_utils.format.datetime( - moment(this.validation_date), {}, {timezone: false}); - }, - - set_tip: function(tip) { - var tip_product = this.pos.db.get_product_by_id(this.pos.config.tip_product_id[0]); - var lines = this.get_orderlines(); - if (tip_product) { - for (var i = 0; i < lines.length; i++) { - if (lines[i].get_product() === tip_product) { - lines[i].set_unit_price(tip); - lines[i].set_lst_price(tip); - lines[i].price_manually_set = true; - lines[i].order.tip_amount = tip; - return; - } - } - return this.add_product(tip_product, { - is_tip: true, - quantity: 1, - price: tip, - lst_price: tip, - extras: {price_manually_set: true}, + }, + set_pricelist: function (pricelist) { + var self = this; + this.pricelist = pricelist; + + var lines_to_recompute = _.filter(this.get_orderlines(), function (line) { + return !line.price_manually_set; }); - } - }, - set_pricelist: function (pricelist) { - var self = this; - this.pricelist = pricelist; - - var lines_to_recompute = _.filter(this.get_orderlines(), function (line) { - return ! line.price_manually_set; - }); - _.each(lines_to_recompute, function (line) { - line.set_unit_price(line.product.get_price(self.pricelist, line.get_quantity(), line.get_price_extra())); - self.fix_tax_included_price(line); - }); - this.trigger('change'); - }, - remove_orderline: function( line ){ - this.assert_editable(); - this.orderlines.remove(line); - this.select_orderline(this.get_last_orderline()); - }, - - fix_tax_included_price: function(line){ - line.set_unit_price(line.compute_fixed_price(line.price)); - }, - - add_product: function(product, options){ - if(this._printed){ - this.destroy(); - return this.pos.get_order().add_product(product, options); - } - this.assert_editable(); - options = options || {}; - var line = new exports.Orderline({}, {pos: this.pos, order: this, product: product}); - this.fix_tax_included_price(line); + _.each(lines_to_recompute, function (line) { + line.set_unit_price(line.product.get_price(self.pricelist, line.get_quantity(), line.get_price_extra())); + self.fix_tax_included_price(line); + }); + this.trigger('change'); + }, + remove_orderline: function (line) { + this.assert_editable(); + this.orderlines.remove(line); + this.select_orderline(this.get_last_orderline()); + }, - if(options.quantity !== undefined){ - line.set_quantity(options.quantity); - } + fix_tax_included_price: function (line) { + line.set_unit_price(line.compute_fixed_price(line.price)); + }, - if (options.price_extra !== undefined){ - line.price_extra = options.price_extra; - line.set_unit_price(line.product.get_price(this.pricelist, line.get_quantity(), options.price_extra)); + add_product: function (product, options) { + if (this._printed) { + this.destroy(); + return this.pos.get_order().add_product(product, options); + } + this.assert_editable(); + options = options || {}; + var line = new exports.Orderline({}, { pos: this.pos, order: this, product: product }); this.fix_tax_included_price(line); - } - if(options.price !== undefined){ - line.set_unit_price(options.price); - this.fix_tax_included_price(line); - } + if (options.quantity !== undefined) { + line.set_quantity(options.quantity); + } - if(options.lst_price !== undefined){ - line.set_lst_price(options.lst_price); - } + if (options.price_extra !== undefined) { + line.price_extra = options.price_extra; + line.set_unit_price(line.product.get_price(this.pricelist, line.get_quantity(), options.price_extra)); + this.fix_tax_included_price(line); + } - if(options.discount !== undefined){ - line.set_discount(options.discount); - } + if (options.price !== undefined) { + line.set_unit_price(options.price); + this.fix_tax_included_price(line); + } - if (options.description !== undefined){ - line.description += options.description; - } + if (options.lst_price !== undefined) { + line.set_lst_price(options.lst_price); + } - if(options.extras !== undefined){ - for (var prop in options.extras) { - line[prop] = options.extras[prop]; + if (options.discount !== undefined) { + line.set_discount(options.discount); } - } - if (options.is_tip) { - this.is_tipped = true; - this.tip_amount = options.price; - } - var to_merge_orderline; - for (var i = 0; i < this.orderlines.length; i++) { - if(this.orderlines.at(i).can_be_merged_with(line) && options.merge !== false){ - to_merge_orderline = this.orderlines.at(i); + if (options.description !== undefined) { + line.description += options.description; } - } - if (to_merge_orderline){ - to_merge_orderline.merge(line); - this.select_orderline(to_merge_orderline); - } else { - this.orderlines.add(line); - this.select_orderline(this.get_last_orderline()); - } - if (options.draftPackLotLines) { - this.selected_orderline.setPackLotLines(options.draftPackLotLines); - } - if (this.pos.config.iface_customer_facing_display) { - this.pos.send_current_order_to_customer_facing_display(); - } - }, - get_selected_orderline: function(){ - return this.selected_orderline; - }, - select_orderline: function(line){ - if(line){ - if(line !== this.selected_orderline){ - // if line (new line to select) is not the same as the old - // selected_orderline, then we set the old line to false, - // and set the new line to true. Also, set the new line as - // the selected_orderline. - if(this.selected_orderline){ - this.selected_orderline.set_selected(false); - } - this.selected_orderline = line; - this.selected_orderline.set_selected(true); - } - }else{ - this.selected_orderline = undefined; - } - }, - deselect_orderline: function(){ - if(this.selected_orderline){ - this.selected_orderline.set_selected(false); - this.selected_orderline = undefined; - } - }, + if (options.extras !== undefined) { + for (var prop in options.extras) { + line[prop] = options.extras[prop]; + } + } + if (options.is_tip) { + this.is_tipped = true; + this.tip_amount = options.price; + } - /* ---- Payment Lines --- */ - add_paymentline: function(payment_method) { - this.assert_editable(); - if (this.electronic_payment_in_progress()) { - return false; - } else { - var newPaymentline = new exports.Paymentline({},{order: this, payment_method:payment_method, pos: this.pos}); - newPaymentline.set_amount(this.get_due()); - this.paymentlines.add(newPaymentline); - this.select_paymentline(newPaymentline); - if(this.pos.config.cash_rounding){ - this.selected_paymentline.set_amount(0); - this.selected_paymentline.set_amount(this.get_due()); + var to_merge_orderline; + for (var i = 0; i < this.orderlines.length; i++) { + if (this.orderlines.at(i).can_be_merged_with(line) && options.merge !== false) { + to_merge_orderline = this.orderlines.at(i); + } + } + if (to_merge_orderline) { + to_merge_orderline.merge(line); + this.select_orderline(to_merge_orderline); + } else { + this.orderlines.add(line); + this.select_orderline(this.get_last_orderline()); } - if (payment_method.payment_terminal) { - newPaymentline.set_payment_status('pending'); + if (options.draftPackLotLines) { + this.selected_orderline.setPackLotLines(options.draftPackLotLines); } - return newPaymentline; - } - }, - get_paymentlines: function(){ - return this.paymentlines.models; - }, - /** - * Retrieve the paymentline with the specified cid - * - * @param {String} cid - */ - get_paymentline: function (cid) { - var lines = this.get_paymentlines(); - return lines.find(function (line) { - return line.cid === cid; - }); - }, - remove_paymentline: function(line){ - this.assert_editable(); - if(this.selected_paymentline === line){ - this.select_paymentline(undefined); - } - this.paymentlines.remove(line); - }, - clean_empty_paymentlines: function() { - var lines = this.paymentlines.models; - var empty = []; - for ( var i = 0; i < lines.length; i++) { - if (!lines[i].get_amount()) { - empty.push(lines[i]); + if (this.pos.config.iface_customer_facing_display) { + this.pos.send_current_order_to_customer_facing_display(); } - } - for ( var i = 0; i < empty.length; i++) { - this.remove_paymentline(empty[i]); - } - }, - select_paymentline: function(line){ - if(line !== this.selected_paymentline){ - if(this.selected_paymentline){ - this.selected_paymentline.set_selected(false); + }, + get_selected_orderline: function () { + return this.selected_orderline; + }, + select_orderline: function (line) { + if (line) { + if (line !== this.selected_orderline) { + // if line (new line to select) is not the same as the old + // selected_orderline, then we set the old line to false, + // and set the new line to true. Also, set the new line as + // the selected_orderline. + if (this.selected_orderline) { + this.selected_orderline.set_selected(false); + } + this.selected_orderline = line; + this.selected_orderline.set_selected(true); + } + } else { + this.selected_orderline = undefined; } - this.selected_paymentline = line; - if(this.selected_paymentline){ - this.selected_paymentline.set_selected(true); + }, + deselect_orderline: function () { + if (this.selected_orderline) { + this.selected_orderline.set_selected(false); + this.selected_orderline = undefined; } - this.trigger('change:selected_paymentline',this.selected_paymentline); - } - }, - electronic_payment_in_progress: function() { - return this.get_paymentlines() - .some(function(pl) { - if (pl.payment_status) { - return !['done', 'reversed'].includes(pl.payment_status); - } else { - return false; + }, + + /* ---- Payment Lines --- */ + add_paymentline: function (payment_method) { + this.assert_editable(); + if (this.electronic_payment_in_progress()) { + return false; + } else { + var newPaymentline = new exports.Paymentline({}, { order: this, payment_method: payment_method, pos: this.pos }); + newPaymentline.set_amount(this.get_due()); + this.paymentlines.add(newPaymentline); + this.select_paymentline(newPaymentline); + if (this.pos.config.cash_rounding) { + this.selected_paymentline.set_amount(0); + this.selected_paymentline.set_amount(this.get_due()); } + + if (payment_method.payment_terminal) { + newPaymentline.set_payment_status('pending'); + } + return newPaymentline; + } + }, + get_paymentlines: function () { + return this.paymentlines.models; + }, + /** + * Retrieve the paymentline with the specified cid + * + * @param {String} cid + */ + get_paymentline: function (cid) { + var lines = this.get_paymentlines(); + return lines.find(function (line) { + return line.cid === cid; }); - }, - /** - * Stops a payment on the terminal if one is running - */ - stop_electronic_payment: function () { - var lines = this.get_paymentlines(); - var line = lines.find(function (line) { - var status = line.get_payment_status(); - return status && !['done', 'reversed', 'reversing', 'pending', 'retry'].includes(status); - }); - if (line) { - line.set_payment_status('waitingCancel'); - line.payment_method.payment_terminal.send_payment_cancel(this, line.cid).finally(function () { - line.set_payment_status('retry'); - }); - } - }, - /* ---- Payment Status --- */ - get_subtotal: function(){ - return round_pr(this.orderlines.reduce((function(sum, orderLine){ - return sum + orderLine.get_display_price(); - }), 0), this.pos.currency.rounding); - }, - get_total_with_tax: function() { - return this.get_total_without_tax() + this.get_total_tax(); - }, - get_total_without_tax: function() { - return round_pr(this.orderlines.reduce((function(sum, orderLine) { - return sum + orderLine.get_price_without_tax(); - }), 0), this.pos.currency.rounding); - }, - get_total_discount: function() { - return round_pr(this.orderlines.reduce((function(sum, orderLine) { - sum += (orderLine.get_unit_price() * (orderLine.get_discount()/100) * orderLine.get_quantity()); - if (orderLine.display_discount_policy() === 'without_discount'){ - sum += ((orderLine.get_lst_price() - orderLine.get_unit_price()) * orderLine.get_quantity()); - } - return sum; - }), 0), this.pos.currency.rounding); - }, - get_total_tax: function() { - if (this.pos.company.tax_calculation_rounding_method === "round_globally") { - // As always, we need: - // 1. For each tax, sum their amount across all order lines - // 2. Round that result - // 3. Sum all those rounded amounts - var groupTaxes = {}; - this.orderlines.each(function (line) { - var taxDetails = line.get_tax_details(); - var taxIds = Object.keys(taxDetails); - for (var t = 0; t 0 ? 1.0 : -1.0; - - var rounding_applied = total - remaining; - rounding_applied *= sign; - // because floor and ceil doesn't include decimals in calculation, we reuse the value of the half-up and adapt it. - if (utils.float_is_zero(rounding_applied, this.pos.currency.decimals)){ - // https://xkcd.com/217/ - return 0; - } else if(Math.abs(this.get_total_with_tax()) < this.pos.cash_rounding[0].rounding) { - return 0; - } else if(this.pos.cash_rounding[0].rounding_method === "UP" && rounding_applied < 0 && remaining > 0) { - rounding_applied += this.pos.cash_rounding[0].rounding; - } - else if(this.pos.cash_rounding[0].rounding_method === "UP" && rounding_applied > 0 && remaining < 0) { - rounding_applied -= this.pos.cash_rounding[0].rounding; - } - else if(this.pos.cash_rounding[0].rounding_method === "DOWN" && rounding_applied > 0 && remaining > 0){ - rounding_applied -= this.pos.cash_rounding[0].rounding; + return round_pr(due, this.pos.currency.rounding); + }, + get_rounding_applied: function () { + if (this.pos.config.cash_rounding) { + const only_cash = this.pos.config.only_round_cash_method; + const paymentlines = this.get_paymentlines(); + const last_line = paymentlines ? paymentlines[paymentlines.length - 1] : false; + const last_line_is_cash = last_line ? last_line.payment_method.is_cash_count == true : false; + if (!only_cash || (only_cash && last_line_is_cash)) { + var remaining = this.get_total_with_tax() - this.get_total_paid(); + var total = round_pr(remaining, this.pos.cash_rounding[0].rounding); + var sign = remaining > 0 ? 1.0 : -1.0; + + var rounding_applied = total - remaining; + rounding_applied *= sign; + // because floor and ceil doesn't include decimals in calculation, we reuse the value of the half-up and adapt it. + if (utils.float_is_zero(rounding_applied, this.pos.currency.decimals)) { + // https://xkcd.com/217/ + return 0; + } else if (Math.abs(this.get_total_with_tax()) < this.pos.cash_rounding[0].rounding) { + return 0; + } else if (this.pos.cash_rounding[0].rounding_method === "UP" && rounding_applied < 0 && remaining > 0) { + rounding_applied += this.pos.cash_rounding[0].rounding; + } + else if (this.pos.cash_rounding[0].rounding_method === "UP" && rounding_applied > 0 && remaining < 0) { + rounding_applied -= this.pos.cash_rounding[0].rounding; + } + else if (this.pos.cash_rounding[0].rounding_method === "DOWN" && rounding_applied > 0 && remaining > 0) { + rounding_applied -= this.pos.cash_rounding[0].rounding; + } + else if (this.pos.cash_rounding[0].rounding_method === "DOWN" && rounding_applied < 0 && remaining < 0) { + rounding_applied += this.pos.cash_rounding[0].rounding; + } + return sign * rounding_applied; } - else if(this.pos.cash_rounding[0].rounding_method === "DOWN" && rounding_applied < 0 && remaining < 0){ - rounding_applied += this.pos.cash_rounding[0].rounding; + else { + return 0; } - return sign * rounding_applied; - } - else { - return 0; } - } - return 0; - }, - has_not_valid_rounding: function() { - if(!this.pos.config.cash_rounding || this.get_total_with_tax() < this.pos.cash_rounding[0].rounding) - return false; + return 0; + }, + has_not_valid_rounding: function () { + if (!this.pos.config.cash_rounding || this.get_total_with_tax() < this.pos.cash_rounding[0].rounding) + return false; - const only_cash = this.pos.config.only_round_cash_method; - var lines = this.paymentlines.models; + const only_cash = this.pos.config.only_round_cash_method; + var lines = this.paymentlines.models; - for(var i = 0; i < lines.length; i++) { - var line = lines[i]; - if (only_cash && !line.payment_method.is_cash_count) - continue; + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + if (only_cash && !line.payment_method.is_cash_count) + continue; - if(!utils.float_is_zero(line.amount - round_pr(line.amount, this.pos.cash_rounding[0].rounding), 6)) - return line; - } - return false; - }, - is_paid: function(){ - return this.get_due() <= 0 && this.check_paymentlines_rounding(); - }, - is_paid_with_cash: function(){ - return !!this.paymentlines.find( function(pl){ - return pl.payment_method.is_cash_count; - }); - }, - check_paymentlines_rounding: function() { - if(this.pos.config.cash_rounding) { - var cash_rounding = this.pos.cash_rounding[0].rounding; - var default_rounding = this.pos.currency.rounding; - for(var id in this.get_paymentlines()) { - var line = this.get_paymentlines()[id]; - var diff = round_pr(round_pr(line.amount, cash_rounding) - round_pr(line.amount, default_rounding), default_rounding); - if(this.get_total_with_tax() < this.pos.cash_rounding[0].rounding) - return true; - if(diff && line.payment_method.is_cash_count) { - return false; - } else if(!this.pos.config.only_round_cash_method && diff) { - return false; + if (!utils.float_is_zero(line.amount - round_pr(line.amount, this.pos.cash_rounding[0].rounding), 6)) + return line; + } + return false; + }, + is_paid: function () { + return this.get_due() <= 0 && this.check_paymentlines_rounding(); + }, + is_paid_with_cash: function () { + return !!this.paymentlines.find(function (pl) { + return pl.payment_method.is_cash_count; + }); + }, + check_paymentlines_rounding: function () { + if (this.pos.config.cash_rounding) { + var cash_rounding = this.pos.cash_rounding[0].rounding; + var default_rounding = this.pos.currency.rounding; + for (var id in this.get_paymentlines()) { + var line = this.get_paymentlines()[id]; + var diff = round_pr(round_pr(line.amount, cash_rounding) - round_pr(line.amount, default_rounding), default_rounding); + if (this.get_total_with_tax() < this.pos.cash_rounding[0].rounding) + return true; + if (diff && line.payment_method.is_cash_count) { + return false; + } else if (!this.pos.config.only_round_cash_method && diff) { + return false; + } } + return true; } return true; - } - return true; - }, - finalize: function(){ - this.destroy(); - }, - destroy: function(){ - Backbone.Model.prototype.destroy.apply(this,arguments); - this.pos.db.remove_unpaid_order(this); - }, - /* ---- Invoice --- */ - set_to_invoice: function(to_invoice) { - this.assert_editable(); - this.to_invoice = to_invoice; - }, - is_to_invoice: function(){ - return this.to_invoice; - }, - /* ---- Client / Customer --- */ - // the client related to the current order. - set_client: function(client){ - this.assert_editable(); - this.set('client',client); - }, - get_client: function(){ - return this.get('client'); - }, - get_client_name: function(){ - var client = this.get('client'); - return client ? client.name : ""; - }, - get_cardholder_name: function(){ - var card_payment_line = this.paymentlines.find(pl => pl.cardholder_name); - return card_payment_line ? card_payment_line.cardholder_name : ""; - }, - /* ---- Screen Status --- */ - // the order also stores the screen status, as the PoS supports - // different active screens per order. This method is used to - // store the screen status. - set_screen_data: function(value){ - this.screen_data['value'] = value; - }, - //see set_screen_data - get_screen_data: function(){ - const screen = this.screen_data['value']; - // If no screen data is saved - // no payment line -> product screen - // with payment line -> payment screen - if (!screen) { - if (this.get_paymentlines().length > 0) return { name: 'PaymentScreen' }; - return { name: 'ProductScreen' }; - } - if (!this.finalized && this.get_paymentlines().length > 0) { - return { name: 'PaymentScreen' }; - } - return screen; - }, - wait_for_push_order: function () { - return false; - }, - /** - * @returns {Object} object to use as props for instantiating OrderReceipt. - */ - getOrderReceiptEnv: function() { - // Formerly get_receipt_render_env defined in ScreenWidget. - return { - order: this, - receipt: this.export_for_printing(), - orderlines: this.get_orderlines(), - paymentlines: this.get_paymentlines(), - }; - }, - updatePricelist: function(newClient) { - let newClientPricelist, newClientFiscalPosition; - const defaultFiscalPosition = this.pos.fiscal_positions.find( - (position) => position.id === this.pos.config.default_fiscal_position_id[0] - ); - if (newClient) { - newClientFiscalPosition = newClient.property_account_position_id - ? this.pos.fiscal_positions.find( - (position) => position.id === newClient.property_account_position_id[0] - ) - : defaultFiscalPosition; - newClientPricelist = - this.pos.pricelists.find( - (pricelist) => pricelist.id === newClient.property_product_pricelist[0] - ) || this.pos.default_pricelist; - } else { - newClientFiscalPosition = defaultFiscalPosition; - newClientPricelist = this.pos.default_pricelist; - } - this.fiscal_position = newClientFiscalPosition; - this.set_pricelist(newClientPricelist); - } -}); - -var OrderCollection = Backbone.Collection.extend({ - model: exports.Order, -}); - -// exports = { -// PosModel: PosModel, -// load_fields: load_fields, -// load_models: load_models, -// Orderline: Orderline, -// Order: Order, -// }; -return exports; + }, + finalize: function () { + this.destroy(); + }, + destroy: function () { + Backbone.Model.prototype.destroy.apply(this, arguments); + this.pos.db.remove_unpaid_order(this); + }, + /* ---- Invoice --- */ + set_to_invoice: function (to_invoice) { + this.assert_editable(); + this.to_invoice = to_invoice; + }, + is_to_invoice: function () { + return this.to_invoice; + }, + /* ---- Client / Customer --- */ + // the client related to the current order. + set_client: function (client) { + this.assert_editable(); + this.set('client', client); + }, + get_client: function () { + return this.get('client'); + }, + get_client_name: function () { + var client = this.get('client'); + return client ? client.name : ""; + }, + get_cardholder_name: function () { + var card_payment_line = this.paymentlines.find(pl => pl.cardholder_name); + return card_payment_line ? card_payment_line.cardholder_name : ""; + }, + /* ---- Screen Status --- */ + // the order also stores the screen status, as the PoS supports + // different active screens per order. This method is used to + // store the screen status. + set_screen_data: function (value) { + this.screen_data['value'] = value; + }, + //see set_screen_data + get_screen_data: function () { + const screen = this.screen_data['value']; + // If no screen data is saved + // no payment line -> product screen + // with payment line -> payment screen + if (!screen) { + if (this.get_paymentlines().length > 0) return { name: 'PaymentScreen' }; + return { name: 'ProductScreen' }; + } + if (!this.finalized && this.get_paymentlines().length > 0) { + return { name: 'PaymentScreen' }; + } + return screen; + }, + wait_for_push_order: function () { + return false; + }, + /** + * @returns {Object} object to use as props for instantiating OrderReceipt. + */ + getOrderReceiptEnv: function () { + // Formerly get_receipt_render_env defined in ScreenWidget. + return { + order: this, + receipt: this.export_for_printing(), + orderlines: this.get_orderlines(), + paymentlines: this.get_paymentlines(), + }; + }, + updatePricelist: function (newClient) { + let newClientPricelist, newClientFiscalPosition; + const defaultFiscalPosition = this.pos.fiscal_positions.find( + (position) => position.id === this.pos.config.default_fiscal_position_id[0] + ); + if (newClient) { + newClientFiscalPosition = newClient.property_account_position_id + ? this.pos.fiscal_positions.find( + (position) => position.id === newClient.property_account_position_id[0] + ) + : defaultFiscalPosition; + newClientPricelist = + this.pos.pricelists.find( + (pricelist) => pricelist.id === newClient.property_product_pricelist[0] + ) || this.pos.default_pricelist; + } else { + newClientFiscalPosition = defaultFiscalPosition; + newClientPricelist = this.pos.default_pricelist; + } + this.fiscal_position = newClientFiscalPosition; + this.set_pricelist(newClientPricelist); + } + }); + + var OrderCollection = Backbone.Collection.extend({ + model: exports.Order, + }); + + // exports = { + // PosModel: PosModel, + // load_fields: load_fields, + // load_models: load_models, + // Orderline: Orderline, + // Order: Order, + // }; + return exports; }); diff --git a/pos_product_available/views/point_of_sale.xml b/pos_product_available/views/point_of_sale.xml index 2cf0445424..1c099b12bb 100644 --- a/pos_product_available/views/point_of_sale.xml +++ b/pos_product_available/views/point_of_sale.xml @@ -1,8 +1,10 @@ - + -