diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/point_of_sale/static/src/js/models.js | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/point_of_sale/static/src/js/models.js')
| -rw-r--r-- | addons/point_of_sale/static/src/js/models.js | 3514 |
1 files changed, 3514 insertions, 0 deletions
diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js new file mode 100644 index 00000000..100596d5 --- /dev/null +++ b/addons/point_of_sale/static/src/js/models.js @@ -0,0 +1,3514 @@ +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; + }); + }, + + },{ + 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; + }); + _.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; + }); + + _.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'], + 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.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); + }); + }); + + 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/binary/company_logo' + '?dbname=' + self.session.db + '&company=' + self.company.id + '&_' + Math.random(); + }); + }, + }, { + 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); + + var cond = typeof model.condition === 'function' ? model.condition(self,tmp) : true; + if (!cond) { + load_model(index+1); + return; + } + + 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, tmp)) + .then(function () { load_model(index +1); }, + function (err) { reject(err); }); + } catch (err) { + reject(err); + } + } else { + load_model(index + 1); + } + } + } + + try { + return load_model(0); + } catch (err) { + return Promise.reject(err); + } + }); + + return loaded; + }, + + 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('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 }); + } + }, + + // 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: function(){ + var jsons = this.db.get_unpaid_orders(); + 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) { + 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; + }); + + if (orders.length) { + this.get('orders').add(orders); + } + }, + + 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(); + } + }, + + // return the current order + get_order: function(){ + return this.get('selectedOrder'); + }, + + 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'}); + } + }, + + _convert_product_img_to_base64: function (product, url) { + return new Promise(function (resolve, reject) { + var img = new Image(); + + 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<string>} + */ + 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)); + } + }); + } + + // 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 (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()); + } + + 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); + + flushed.then(resolve, reject); + + return flushed; + }); + }); + }, + + 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); + + 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()); + + 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(); + } 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 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); + + 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); + }); + }, + + 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([]); + } + + options = options || {}; + + 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'); + + // 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({ + model: 'pos.order', + method: 'create_from_ui', + args: args, + 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; + } + } + throw error; + }); + }, + + /** + * Remove orders with given ids from the database. + * @param {array<number>} server_ids ids of the orders to be removed. + * @param {dict} options. + * @param {number} options.timeout optional timeout parameter for the rpc call. + * @return {Promise<array<number>>} returns a promise of the ids successfully removed. + */ + _remove_from_server: function (server_ids, options) { + options = options || {}; + if (!server_ids || !server_ids.length) { + return Promise.resolve([]); + } + + var self = this; + var timeout = typeof options.timeout === 'number' ? options.timeout : 7500 * server_ids.length; + + return this.rpc({ + model: 'pos.order', + method: 'remove_from_ui', + args: [server_ids], + 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; + } + } + // 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}); + }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(); + } + + if (json.unpaid_orders) { + + var orders = []; + var existing = this.get_order_list(); + var existing_uids = {}; + var skipped_sessions = {}; + + for (var i = 0; i < existing.length; i++) { + existing_uids[existing[i].uid] = true; + } + + 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, + })); + } + } + + 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; + } + } + + 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) { + this.get('orders').add(orders); + } + }, + + /** + * 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], + }); + } + + return amount; + }, + + 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 : '$'; + }, +}); + +/** + * 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 || []); + } + } + } +}; + +// 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; + } + } + } 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)); +}; + +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().startOf('day'); + + // 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(item.date_start).isSameOrBefore(date)) && + (! item.date_end || moment(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 (rule.base === 'pricelist') { + price = self.get_price(rule.base_pricelist, quantity); + } else if (rule.base === 'standard_price') { + price = self.standard_price; + } + + if (rule.compute_price === 'fixed') { + price = rule.fixed_price; + return true; + } else if (rule.compute_price === 'percentage') { + price = price - (price * (rule.percent_price / 100)); + return true; + } else { + var price_limit = price; + price = price - (price * (rule.price_discount / 100)); + if (rule.price_round) { + price = round_pr(price, rule.price_round); + } + if (rule.price_surcharge) { + price += rule.price_surcharge; + } + if (rule.price_min_margin) { + price = Math.max(price, price_limit + rule.price_min_margin); + } + if (rule.price_max_margin) { + price = Math.min(price, price_limit + rule.price_max_margin); + } + return true; + } + + return false; + }); + + // This return value has to be rounded with round_di before + // being used further. Note that this cannot happen here, + // because it would cause inconsistencies with the backend for + // pricelist that have base == 'pricelist'. + return price; + }, +}); + +var orderline_id = 1; + +// An orderline represent one element of the content of a client's shopping cart. +// 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 = 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); + } + } + + // Remove those that needed to be removed. + for (let lotLine of lotLinesToRemove) { + lotLine.remove(); + } + + // 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); + } + + // Set the quantity of the line based on number of pack lots. + 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 = 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; + } + } + + // just like in sale.order changing the quantity will recompute the unit price + if(! keep_price && ! this.price_manually_set){ + 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; + } + }, + + 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); + } + + 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 = orderline.compute_fixed_price(order_line_price); + 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); + } + + if (current_line.length) { + wrapped.push(current_line); + } + + 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_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 taxes = this.pos.taxes; + var product_taxes = []; + + _(taxes_ids).each(function(el){ + product_taxes.push(_.detect(taxes, function(t){ + return t.id === el; + })); + }); + + var all_taxes = this.compute_all(product_taxes, price_unit, 1, this.pos.currency.rounding); + + 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_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]]); + } + } + return taxes; + }, + _map_tax_fiscal_position: function(tax, order = false) { + var self = this; + var current_order = order || this.pos.get_order(); + var order_fiscal_position = current_order && current_order.fiscal_position; + var 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; + }); + + if (tax_mappings && tax_mappings.length) { + _.each(tax_mappings, function(tm) { + if (tm.tax_dest_id) { + taxes.push(self.pos.taxes_by_id[tm.tax_dest_id[0]]); + } + }); + } else{ + taxes.push(tax); + } + } else { + taxes.push(tax); + } + + return taxes; + }, + /** + * 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; + + // 1) Flatten the 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, []); + } + + taxes = collect_taxes(taxes); + + // 2) Deal with the rounding methods + + var round_tax = this.pos.company.tax_calculation_rounding_method != 'round_globally'; + + var initial_currency_rounding = currency_rounding; + if(!round_tax) + currency_rounding = currency_rounding * 0.00001; + + // 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 base = round_pr(price_unit * quantity, initial_currency_rounding); + + var sign = 1; + if(base < 0){ + base = -base; + sign = -1; + } + + 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; + } + if(store_included_tax_total){ + total_included_checkpoints[i] = base; + store_included_tax_total = false; + } + } + 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; + + // 4) Iterate the taxes in the sequence order to fill missing base/amount values. + + base = total_excluded; + + 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); + + 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.include_base_amount){ + base += tax_amount; + if(!tax.price_include) + skip_checkpoint = true; + } + + total_included += tax_amount; + i += 1; + }); + + return { + 'taxes': taxes_vals, + 'total_excluded': sign * round_pr(total_excluded, this.pos.currency.rounding), + 'total_included': sign * round_pr(total_included, this.pos.currency.rounding), + } + }, + get_all_prices: function(){ + var self = this; + + var price_unit = this.get_unit_price() * (1.0 - (this.get_discount() / 100.0)); + var taxtotal = 0; + + var product = this.get_product(); + var taxes = this.pos.taxes; + var taxes_ids = _.filter(product.taxes_id, t => t in this.pos.taxes_by_id); + var taxdetail = {}; + 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, self.order)); + }); + product_taxes = _.uniq(product_taxes, function(tax) { return tax.id; }); + + 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); + } + }); + + 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; + } + } + } + return price; + }, + get_fixed_lst_price: function(){ + return this.compute_fixed_price(this.get_lst_price()); + }, + get_lst_price: function(){ + return this.product.lst_price; + }, + 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; + }, +}); + +var OrderlineCollection = Backbone.Collection.extend({ + model: exports.Orderline, +}); + +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); + } +}); + +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); + } +}); + +// 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.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 the new payment status. + * + * @param {string} value - new status. + */ + set_payment_status: function(value) { + this.payment_status = value; + this.trigger('change', 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; + }, + + /** + * 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); + }, + + /** + * 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, + 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 || {}; + + 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: '', + }) + }; + + 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]; + }); + } + + 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); + } + + this.save_to_db(); + + return this; + }, + save_to_db: function(){ + if (!this.temporary && !this.locked) { + 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 (fiscal_position) { + this.fiscal_position = fiscal_position; + } else { + console.error('ERROR: trying to load a fiscal position not available in the pos'); + } + } + + if (json.pricelist_id) { + this.pricelist = _.find(this.pos.pricelists, function (pricelist) { + return pricelist.id === json.pricelist_id; + }); + } else { + this.pricelist = this.pos.default_pricelist; + } + + if (json.partner_id) { + client = this.pos.db.get_partner_by_id(json.partner_id); + if (!client) { + console.error('ERROR: trying to load a partner not available in the pos'); + } + } else { + client = null; + } + this.set_client(client); + + this.temporary = false; // FIXME + this.to_invoice = false; // FIXME + + var orderlines = json.lines; + for (var i = 0; i < orderlines.length; i++) { + var orderline = orderlines[i][2]; + this.add_orderline(new exports.Orderline({}, {pos: this.pos, order: this, json: orderline})); + } + + 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; + + 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(); + }); + 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('<!DOCTYPE QWEB') >= 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('<templates><t t-name="subreceipt">'+subreceipt+'</t></templates>'); + + 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 || ''; + } + + 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(); + } + } + 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; + }); + _.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); + + if(options.quantity !== undefined){ + line.set_quantity(options.quantity); + } + + 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.price !== undefined){ + line.set_unit_price(options.price); + this.fix_tax_included_price(line); + } + + if(options.lst_price !== undefined){ + line.set_lst_price(options.lst_price); + } + + if(options.discount !== undefined){ + line.set_discount(options.discount); + } + + if (options.description !== undefined){ + line.description += options.description; + } + + 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; + } + + 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 (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; + } + }, + + /* ---- Payment Lines --- */ + add_paymentline: function(payment_method) { + this.assert_editable(); + 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()); + } + 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]); + } + } + 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); + } + this.selected_paymentline = line; + if(this.selected_paymentline){ + this.selected_paymentline.set_selected(true); + } + 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; + } + }); + }, + /** + * 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<taxIds.length; t++) { + var taxId = taxIds[t]; + if (!(taxId in groupTaxes)) { + groupTaxes[taxId] = 0; + } + groupTaxes[taxId] += taxDetails[taxId]; + } + }); + + var sum = 0; + var taxIds = Object.keys(groupTaxes); + for (var j = 0; j<taxIds.length; j++) { + var taxAmount = groupTaxes[taxIds[j]]; + sum += round_pr(taxAmount, this.pos.currency.rounding); + } + return sum; + } else { + return round_pr(this.orderlines.reduce((function(sum, orderLine) { + return sum + orderLine.get_tax(); + }), 0), this.pos.currency.rounding); + } + }, + get_total_paid: function() { + return round_pr(this.paymentlines.reduce((function(sum, paymentLine) { + if (paymentLine.is_done()) { + sum += paymentLine.get_amount(); + } + return sum; + }), 0), this.pos.currency.rounding); + }, + get_tax_details: function(){ + var details = {}; + var fulldetails = []; + + this.orderlines.each(function(line){ + var ldetails = line.get_tax_details(); + for(var id in ldetails){ + if(ldetails.hasOwnProperty(id)){ + details[id] = (details[id] || 0) + ldetails[id]; + } + } + }); + + for(var id in details){ + if(details.hasOwnProperty(id)){ + fulldetails.push({amount: details[id], tax: this.pos.taxes_by_id[id], name: this.pos.taxes_by_id[id].name}); + } + } + + return fulldetails; + }, + // Returns a total only for the orderlines with products belonging to the category + get_total_for_category_with_tax: function(categ_id){ + var total = 0; + var self = this; + + if (categ_id instanceof Array) { + for (var i = 0; i < categ_id.length; i++) { + total += this.get_total_for_category_with_tax(categ_id[i]); + } + return total; + } + + this.orderlines.each(function(line){ + if ( self.pos.db.category_contains(categ_id,line.product.id) ) { + total += line.get_price_with_tax(); + } + }); + + return total; + }, + get_total_for_taxes: function(tax_id){ + var total = 0; + + if (!(tax_id instanceof Array)) { + tax_id = [tax_id]; + } + + var tax_set = {}; + + for (var i = 0; i < tax_id.length; i++) { + tax_set[tax_id[i]] = true; + } + + this.orderlines.each(function(line){ + var taxes_ids = line.get_product().taxes_id; + for (var i = 0; i < taxes_ids.length; i++) { + if (tax_set[taxes_ids[i]]) { + total += line.get_price_with_tax(); + return; + } + } + }); + + return total; + }, + get_change: function(paymentline) { + if (!paymentline) { + var change = this.get_total_paid() - this.get_total_with_tax() - this.get_rounding_applied(); + } else { + var change = -this.get_total_with_tax(); + var lines = this.paymentlines.models; + for (var i = 0; i < lines.length; i++) { + change += lines[i].get_amount(); + if (lines[i] === paymentline) { + break; + } + } + } + return round_pr(Math.max(0,change), this.pos.currency.rounding); + }, + get_due: function(paymentline) { + if (!paymentline) { + var due = this.get_total_with_tax() - this.get_total_paid() + this.get_rounding_applied(); + } else { + var due = this.get_total_with_tax(); + var lines = this.paymentlines.models; + for (var i = 0; i < lines.length; i++) { + if (lines[i] === paymentline) { + break; + } else { + due -= lines[i].get_amount(); + } + } + } + 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(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 { + return 0; + } + } + return 0; + }, + has_not_valid_rounding: function() { + if(!this.pos.config.cash_rounding) + return false; + + 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; + + 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(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; + }, + 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; + +}); |
