summaryrefslogtreecommitdiff
path: root/addons/point_of_sale/static/src/js/models.js
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/point_of_sale/static/src/js/models.js
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (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.js3514
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;
+
+});