From d9dca8feb76d259f760082688392714af4d665b2 Mon Sep 17 00:00:00 2001 From: Robin Heinz Date: Wed, 19 May 2021 14:26:19 +0000 Subject: [PATCH] [IMP] point_of_sale: Limit product and partner load In order to improve the perf at the opening of the point of sale, this commit allows to only load a limited quantity of products and partners in the POS. This also include a new option that allows to load them in the background if they're not all loaded. Part-of: odoo/odoo#72108 --- addons/point_of_sale/models/pos_config.py | 55 ++++++++ addons/point_of_sale/static/src/css/pos.css | 13 +- .../ClientListScreen/ClientListScreen.js | 41 +++++- .../OrderManagementScreen/OrderFetcher.js | 0 .../Screens/ProductScreen/ProductsWidget.js | 11 +- .../ProductsWidgetControlPanel.js | 34 +++++ addons/point_of_sale/static/src/js/models.js | 133 ++++++++++++++++-- .../ClientListScreen/ClientListScreen.xml | 10 ++ .../ProductsWidgetControlPanel.xml | 5 + .../point_of_sale/views/pos_config_view.xml | 43 ++++++ 10 files changed, 323 insertions(+), 22 deletions(-) create mode 100644 addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderFetcher.js diff --git a/addons/point_of_sale/models/pos_config.py b/addons/point_of_sale/models/pos_config.py index 7ff965b2cd5..1e4e71c020e 100644 --- a/addons/point_of_sale/models/pos_config.py +++ b/addons/point_of_sale/models/pos_config.py @@ -229,6 +229,18 @@ class PosConfig(models.Model): string='Shipping Policy', required=True, default='direct', help="If you deliver all products at once, the delivery order will be scheduled based on the greatest " "product lead time. Otherwise, it will be based on the shortest.") + limited_products_loading = fields.Boolean('Limited Product Loading', + help="we load all starred products (favorite), all services, recent inventory movements of products, and the most recently updated products.\n" + "When the session is open, we keep on loading all remaining products in the background.\n" + "In the meantime, you can click on the 'database icon' in the searchbar to load products from database.") + limited_products_amount = fields.Integer(default=20000) + product_load_background = fields.Boolean() + limited_partners_loading = fields.Boolean('Limited Partners Loading', + help="By default, 100 partners are loaded.\n" + "When the session is open, we keep on loading all remaining partners in the background.\n" + "In the meantime, you can use the 'Load Customers' button to load partners from database.") + limited_partners_amount = fields.Integer(default=100) + partner_load_background = fields.Boolean() @api.depends('use_pricelist', 'available_pricelist_ids') def _compute_allowed_pricelist_ids(self): @@ -722,3 +734,46 @@ class PosConfig(models.Model): pos_config.write({'invoice_journal_id': invoice_journal_id.id}) else: pos_config.write({'module_account': False}) + + def get_limited_products_loading(self): + self.env.cr.execute(""" + WITH pm AS + ( + SELECT product_id, + Max(write_date) date + FROM stock_quant + GROUP BY product_id) + SELECT p.id + FROM product_product p + LEFT JOIN product_template t + ON ( + product_tmpl_id=t.id) + LEFT JOIN pm + ON ( + p.id=pm.product_id) + WHERE p.active + AND t.available_in_pos + ORDER BY t.priority DESC, + t.type DESC, + COALESCE(pm.date,p.write_date) DESC limit %s + """, [str(self.limited_products_amount)]) + return self.env.cr.fetchall() + + def get_limited_partners_loading(self): + self.env.cr.execute(""" + WITH pm AS + ( + SELECT partner_id, + Count(partner_id) order_count + FROM pos_order + GROUP BY partner_id) + SELECT id + FROM res_partner AS partner + LEFT JOIN pm + ON ( + partner.id = pm.partner_id) + ORDER BY COALESCE(pm.order_count, 0) DESC, + NAME limit %s; + """, [str(self.limited_partners_amount)]) + result = self.env.cr.fetchall() + return result diff --git a/addons/point_of_sale/static/src/css/pos.css b/addons/point_of_sale/static/src/css/pos.css index aeea5c81cd1..f53c6bd20d3 100644 --- a/addons/point_of_sale/static/src/css/pos.css +++ b/addons/point_of_sale/static/src/css/pos.css @@ -3454,7 +3454,7 @@ td { .search-bar-portal .search-box .icon { position: absolute; left: 0; - margin-left: 12px; + margin-left: 4px; z-index: 1; color: #4f5b66; } @@ -3462,7 +3462,16 @@ td { .search-bar-portal .search-box .clear-icon { position: absolute; right: 0; - margin-right: 12px; + margin-right: 24px; + z-index: 1; + color: #4f5b66; + cursor: pointer; +} + +.search-bar-portal .search-box .database-icon { + position: absolute; + left: 0; + margin-left: 22px; z-index: 1; color: #4f5b66; cursor: pointer; diff --git a/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js index 4863d588e04..d19d2f73320 100644 --- a/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js +++ b/addons/point_of_sale/static/src/js/Screens/ClientListScreen/ClientListScreen.js @@ -47,7 +47,6 @@ odoo.define('point_of_sale.ClientListScreen', function(require) { }; this.updateClientList = debounce(this.updateClientList, 70); } - // Lifecycle hooks back() { if(this.state.detailIsShown) { @@ -69,11 +68,13 @@ odoo.define('point_of_sale.ClientListScreen', function(require) { } get clients() { + let res; if (this.state.query && this.state.query.trim() !== '') { - return this.env.pos.db.search_partner(this.state.query.trim()); + res = this.env.pos.db.search_partner(this.state.query.trim()); } else { - return this.env.pos.db.get_partners_sorted(1000); + res = this.env.pos.db.get_partners_sorted(1000); } + return res.sort(function (a, b) { return a.name.localeCompare(b.name) }); } get isNextButtonVisible() { return this.state.selectedClient ? true : false; @@ -96,7 +97,8 @@ odoo.define('point_of_sale.ClientListScreen', function(require) { // We declare this event handler as a debounce function in // order to lower its trigger rate. - updateClientList(event) { + async updateClientList(event) { + var newClientList = await this.getNewClient(); this.state.query = event.target.value; const clients = this.clients; if (event.code === 'Enter' && clients.length === 1) { @@ -173,6 +175,37 @@ odoo.define('point_of_sale.ClientListScreen', function(require) { cancelEdit() { this.deactivateEditMode(); } + async searchClient() { + let result = await this.getNewClient(); + this.env.pos.db.add_partners(result); + if(!result.length) { + await this.showPopup('ErrorPopup', { + title: this.env._t(''), + body: this.env._t('No customer found'), + }); + } + this.render(); + } + async getNewClient() { + var domain = []; + if(this.state.query) { + domain = [["name", "ilike", this.state.query + "%"]]; + } + var fields = _.find(this.env.pos.models, function(model){ return model.label === 'load_partners'; }).fields; + var result = await this.rpc({ + model: 'res.partner', + method: 'search_read', + args: [domain, fields], + kwargs: { + limit: 10, + }, + },{ + timeout: 3000, + shadow: true, + }); + + return result; + } } ClientListScreen.template = 'ClientListScreen'; diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderFetcher.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderFetcher.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js index 17481058a40..10834b3f3c9 100644 --- a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidget.js @@ -17,6 +17,7 @@ odoo.define('point_of_sale.ProductsWidget', function(require) { useListener('update-search', this._updateSearch); useListener('try-add-product', this._tryAddProduct); useListener('clear-search', this._clearSearch); + useListener('update-product-list', this._updateProductList); this.state = useState({ searchWord: '' }); } mounted() { @@ -32,14 +33,16 @@ odoo.define('point_of_sale.ProductsWidget', function(require) { return this.state.searchWord.trim(); } get productsToDisplay() { + let list = []; if (this.searchWord !== '') { - return this.env.pos.db.search_product_in_category( + list = this.env.pos.db.search_product_in_category( this.selectedCategoryId, this.searchWord ); } else { - return this.env.pos.db.get_product_by_category(this.selectedCategoryId); + list = this.env.pos.db.get_product_by_category(this.selectedCategoryId); } + return list.sort(function (a, b) { return a.display_name.localeCompare(b.display_name) }); } get subcategories() { return this.env.pos.db @@ -79,6 +82,10 @@ odoo.define('point_of_sale.ProductsWidget', function(require) { _clearSearch() { this.state.searchWord = ''; } + _updateProductList(event) { + this.render(); + this.trigger('switch-category', 0); + } } ProductsWidget.template = 'ProductsWidget'; diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js index b232cacc19c..637680f8b7c 100644 --- a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js +++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductsWidgetControlPanel.js @@ -3,6 +3,8 @@ odoo.define('point_of_sale.ProductsWidgetControlPanel', function(require) { const { useRef } = owl.hooks; const { debounce } = owl.utils; + const { identifyError } = require('point_of_sale.utils'); + const { ConnectionLostError, ConnectionAbortedError } = require('@web/core/network/rpc_service'); const PosComponent = require('point_of_sale.PosComponent'); const Registries = require('point_of_sale.Registries'); const { posbus } = require('point_of_sale.utils'); @@ -37,6 +39,38 @@ odoo.define('point_of_sale.ProductsWidgetControlPanel', function(require) { this.trigger('switch-category', 0); this.trigger('update-search', productName); } + async loadProductFromDB() { + if(!this.searchWordInput.el.value) + return; + + try { + let ProductIds = await this.rpc({ + model: 'product.product', + method: 'search', + args: [[['name', 'ilike', this.searchWordInput.el.value + "%"]]], + context: this.env.session.user_context, + }); + if(!ProductIds.length) { + this.showPopup('ErrorPopup', { + title: this.env._t(''), + body: this.env._t("No product found"), + }); + } else { + await this.env.pos._addProducts(ProductIds); + } + this.trigger('update-product-list'); + } catch (error) { + const identifiedError = identifyError(error) + if (identifiedError instanceof ConnectionLostError || identifiedError instanceof ConnectionAbortedError) { + return this.showPopup('OfflineErrorPopup', { + title: this.env._t('Network Error'), + body: this.env._t("Product is not loaded. Tried loading the product from the server but there is a network error."), + }); + } else { + throw error; + } + } + } } ProductsWidgetControlPanel.template = 'ProductsWidgetControlPanel'; diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index d4fef3e320c..9a6da2ffa79 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -147,6 +147,13 @@ exports.PosModel = Backbone.Model.extend({ return this.connect_to_proxy(); } + if(this.config.limited_products_loading) { + await this.env.pos._addProducts(await this.loadProductsFromOffset(0)); + if(this.config.product_load_background) + this.loadProductsBackground(); + } + if(this.config.partner_load_background ) + this.loadPartnersBackground(); return Promise.resolve(); }, // releases ressources holds by the model at the end of life of the posmodel @@ -245,16 +252,6 @@ exports.PosModel = Backbone.Model.extend({ 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'], @@ -347,6 +344,27 @@ exports.PosModel = Backbone.Model.extend({ self.pos_session.sequence_number = Math.max(self.pos_session.sequence_number, orders[i].data.sequence_number+1); } }, + },{ + 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'], + domain: async function(self){ + if(self.config.limited_partners_loading) { + const result = await self.rpc({ + model: 'pos.config', + method: 'get_limited_partners_loading', + args: [self.config.id], + }); + return [['id','in', result.map(elem => elem[0])]]; + } + return []; + }, + loaded: function(self,partners){ + self.partners = partners; + self.db.add_partners(partners); + }, },{ model: 'stock.picking.type', fields: ['use_create_lots', 'use_existing_lots'], @@ -454,6 +472,8 @@ exports.PosModel = Backbone.Model.extend({ }, },{ model: 'product.product', + label: 'load_products', + condition: function (self) { return !self.config.limited_products_loading; }, 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'], @@ -468,6 +488,7 @@ exports.PosModel = Backbone.Model.extend({ domain.unshift(['id', '=', self.config.tip_product_id[0]]); domain.unshift('|'); } + return domain; }, context: function(self){ return { display_default_code: false }; }, @@ -663,7 +684,7 @@ exports.PosModel = Backbone.Model.extend({ var tmp = {}; // this is used to share a temporary state between models loaders var loaded = new Promise(function (resolve, reject) { - function load_model(index) { + async function load_model(index) { if (index >= self.models.length) { resolve(); } else { @@ -677,13 +698,13 @@ exports.PosModel = Backbone.Model.extend({ } 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 domain = typeof model.domain === 'function' ? await 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 ){ + if(model.model ){ var params = { model: model.model, context: _.extend(context, self.session.user_context || {}), @@ -845,7 +866,91 @@ exports.PosModel = Backbone.Model.extend({ this.get('orders').add(orders); } }, - + async _loadMissingProducts(orders) { + const missingProductIds = new Set([]); + for (const order of orders) { + for (const line of order.lines) { + const productId = line[2].product_id; + if (missingProductIds.has(productId)) continue; + if (!this.db.get_product_by_id(productId)) { + missingProductIds.add(productId); + } + } + } + const productModel = _.find(this.models, function(model){return model.model === 'product.product';}); + const fields = productModel.fields; + const products = await this.rpc({ + model: 'product.product', + method: 'read', + args: [[...missingProductIds], fields], + context: Object.assign(this.session.user_context, { display_default_code: false }), + }); + productModel.loaded(this, products); + }, + async _loadMissingPartners(orders) { + const missingPartnerIds = new Set([]); + for (const order of orders) { + const partnerId = order.partner_id; + if(missingPartnerIds.has(partnerId)) continue; + if (partnerId && !this.db.get_partner_by_id(partnerId)) { + missingPartnerIds.add(partnerId); + } + } + const partnerModel = _.find(this.models, function(model){return model.model === 'res.partner';}); + const fields = partnerModel.fields; + if(missingPartnerIds) { + const partners = await this.rpc({ + model: 'res.partner', + method: 'read', + args: [[...missingPartnerIds], fields], + context: Object.assign(this.session.user_context, { display_default_code: false }), + }); + partnerModel.loaded(this, partners); + } + }, + loadProductsFromOffset: async function(offset) { + let ProductIds = []; + let product_model = _.find(this.models, (model) => model.model === 'product.product'); + ProductIds = await this.rpc({ + model: 'product.product', + method: 'search', + args: [product_model.domain(this)], + kwargs: { + offset: this.env.pos.config.limited_products_amount * offset, + limit: this.env.pos.config.limited_products_amount, + }, + context: this.env.session.user_context, + }); + return ProductIds; + }, + loadProductsBackground: async function() { + let offset = 0 + let ProductIds = []; + do { + ProductIds = await this.loadProductsFromOffset(offset) + this.env.pos._addProducts(ProductIds); + offset += 1; + } while(ProductIds.length); + }, + loadPartnersBackground: async function() { + let i = 1; + let PartnerIds = []; + var fields = _.find(this.env.pos.models, function(model){ return model.label === 'load_partners'; }).fields; + do { + PartnerIds = await this.rpc({ + model: 'res.partner', + method: 'search_read', + args: [[], fields], + kwargs: { + limit: this.env.pos.config.limited_partners_amount, + offset: this.env.pos.config.limited_partners_amount * i + }, + context: this.env.session.user_context, + }); + this.env.pos.db.add_partners(PartnerIds); + i += 1; + } while(PartnerIds.length); + }, set_start_order: function(){ var orders = this.get('orders').models; diff --git a/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientListScreen.xml b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientListScreen.xml index 11ed3fa5076..597bdf75ff9 100644 --- a/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientListScreen.xml +++ b/addons/point_of_sale/static/src/xml/Screens/ClientListScreen/ClientListScreen.xml @@ -44,6 +44,16 @@ +
+ + + + + + Load Customers + + +
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml index 2c7d7727f7e..e44943716af 100644 --- a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml +++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/ProductsWidgetControlPanel.xml @@ -39,10 +39,15 @@
diff --git a/addons/point_of_sale/views/pos_config_view.xml b/addons/point_of_sale/views/pos_config_view.xml index ea948de5019..7b7ab39da82 100644 --- a/addons/point_of_sale/views/pos_config_view.xml +++ b/addons/point_of_sale/views/pos_config_view.xml @@ -168,6 +168,27 @@ +
+
+ +
+
+
+

Connected Devices

@@ -566,6 +587,28 @@
+
+
+ +
+
+
+
+

Accounting