From 216fa84fbe2cf9ff46d8244dc46764074ebbcf62 Mon Sep 17 00:00:00 2001 From: Horacio Tellez Date: Thu, 24 Nov 2022 08:59:34 +0000 Subject: [PATCH] [ADD] sale: add a products catalog view on the sale order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, Fields Sales are struggling to create quotations or sale orders directly from the customer place. One of the issues is that it is not easy to quickly add products to the SO. Products must be added one by one and, in mobile, it requires completing a form with quantities, ... for each new line.   This commit introduces a catalog view that allows users to select products in a faster and easier way than before, both in desktop and mobile view. Task - 3062080 closes odoo/odoo#106382 Related: odoo/enterprise#37915 Signed-off-by: Victor Feyens (vfe) Co-authored-by: chevalierv --- addons/delivery/controllers/__init__.py | 3 + addons/delivery/controllers/catalog.py | 24 ++++ addons/event_sale/models/sale_order.py | 10 ++ addons/sale/__manifest__.py | 2 + addons/sale/controllers/__init__.py | 1 + addons/sale/controllers/catalog.py | 98 ++++++++++++++++ addons/sale/models/product_product.py | 33 +++++- addons/sale/models/sale_order.py | 10 ++ addons/sale/models/sale_order_line.py | 32 +++++ .../src/js/product_catalog/kanban_model.js | 25 ++++ .../src/js/product_catalog/kanban_record.js | 110 ++++++++++++++++++ .../src/js/product_catalog/kanban_record.xml | 22 ++++ .../src/js/product_catalog/kanban_view.js | 18 +++ .../sale_order_line/sale_order_line.js | 26 +++++ .../sale_order_line/sale_order_line.scss | 11 ++ .../sale_order_line/sale_order_line.xml | 44 +++++++ addons/sale/views/product_product_views.xml | 96 +++++++++++++++ addons/sale/views/sale_order_views.xml | 11 ++ addons/sale_stock/__manifest__.py | 1 + addons/sale_stock/models/sale_order_line.py | 5 + .../views/sale_product_kanban_views.xml | 21 ++++ 21 files changed, 602 insertions(+), 1 deletion(-) create mode 100644 addons/delivery/controllers/__init__.py create mode 100644 addons/delivery/controllers/catalog.py create mode 100644 addons/sale/controllers/catalog.py create mode 100644 addons/sale/static/src/js/product_catalog/kanban_model.js create mode 100644 addons/sale/static/src/js/product_catalog/kanban_record.js create mode 100644 addons/sale/static/src/js/product_catalog/kanban_record.xml create mode 100644 addons/sale/static/src/js/product_catalog/kanban_view.js create mode 100644 addons/sale/static/src/js/product_catalog/sale_order_line/sale_order_line.js create mode 100644 addons/sale/static/src/js/product_catalog/sale_order_line/sale_order_line.scss create mode 100644 addons/sale/static/src/js/product_catalog/sale_order_line/sale_order_line.xml create mode 100644 addons/sale/views/product_product_views.xml create mode 100644 addons/sale_stock/views/sale_product_kanban_views.xml diff --git a/addons/delivery/controllers/__init__.py b/addons/delivery/controllers/__init__.py new file mode 100644 index 00000000000..843112eeb52 --- /dev/null +++ b/addons/delivery/controllers/__init__.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import catalog diff --git a/addons/delivery/controllers/catalog.py b/addons/delivery/controllers/catalog.py new file mode 100644 index 00000000000..7fd42447847 --- /dev/null +++ b/addons/delivery/controllers/catalog.py @@ -0,0 +1,24 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.http import request, Controller + + +class CatalogController(Controller): + + def sale_product_catalog_update_sale_order_line_info(self, order_id, product_id, quantity): + """ Override of `sale` to recompute the delivery prices. + + :param int order_id: The sale order, as a `sale.order` id. + :param int product_id: The product, as a `product.product` id. + :param float quantity: The quantity selected in the product catalog. + :return: The unit price price of the product, based on the pricelist of the sale order and + the quantity selected. + :rtype: float + """ + price_unit = super.sale_product_catalog_update_sale_order_line_info( + order_id, product_id, quantity + ) + order = request.env['sale.order'].browse(order_id) + if order: + order.onchange_order_line() + return price_unit diff --git a/addons/event_sale/models/sale_order.py b/addons/event_sale/models/sale_order.py index a79fa9218b1..fb47bce46c6 100644 --- a/addons/event_sale/models/sale_order.py +++ b/addons/event_sale/models/sale_order.py @@ -1,6 +1,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models, _ +from odoo.osv import expression class SaleOrder(models.Model): @@ -55,3 +56,12 @@ class SaleOrder(models.Model): def unlink(self): self.order_line._unlink_associated_registrations() return super(SaleOrder, self).unlink() + + def _get_product_catalog_domain(self): + """Override of `_get_product_catalog_domain` to extend the domain. + + :returns: A list of tuples that represents a domain. + :rtype: list + """ + domain = super()._get_product_catalog_domain() + return expression.AND([domain, [('detailed_type', '!=', 'event')]]) diff --git a/addons/sale/__manifest__.py b/addons/sale/__manifest__.py index 729a5ae8274..2732d84d9c4 100644 --- a/addons/sale/__manifest__.py +++ b/addons/sale/__manifest__.py @@ -48,6 +48,7 @@ This module contains all the common features of Sales Management and eCommerce. 'views/payment_templates.xml', 'views/payment_views.xml', 'views/product_packaging_views.xml', + 'views/product_product_views.xml', 'views/product_views.xml', 'views/res_partner_views.xml', 'views/sale_onboarding_views.xml', @@ -69,6 +70,7 @@ This module contains all the common features of Sales Management and eCommerce. 'sale/static/src/js/tours/sale.js', 'sale/static/src/js/product_discount_field.js', 'sale/static/src/js/sale_product_field.js', + 'sale/static/src/js/product_catalog/**/*', 'sale/static/src/xml/**/*', ], 'web.assets_frontend': [ diff --git a/addons/sale/controllers/__init__.py b/addons/sale/controllers/__init__.py index 1a195b71756..85653bbd9df 100644 --- a/addons/sale/controllers/__init__.py +++ b/addons/sale/controllers/__init__.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import catalog from . import onboarding from . import portal diff --git a/addons/sale/controllers/catalog.py b/addons/sale/controllers/catalog.py new file mode 100644 index 00000000000..91670e9403b --- /dev/null +++ b/addons/sale/controllers/catalog.py @@ -0,0 +1,98 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.http import request, route, Controller +from odoo.tools import groupby + + +class CatalogController(Controller): + + @route('/sales/catalog/sale_order_lines_info', auth='user', type='json') + def sale_product_catalog_get_sale_order_lines_info(self, order_id, product_ids): + """ Returns products information to be shown in the catalog. + + :param int order_id: The sale order, as a `sale.order` id. + :param list product_ids: The products currently displayed in the product catalog, as a list + of `product.product` ids. + :rtype: dict + :return: A dict with the following structure: + { + 'productId': int + 'quantity': float (optional) + 'price': float + 'readOnly': bool (optional) + } + """ + order = request.env['sale.order'].browse(order_id) + sale_order_line_info = dict() + for product, lines in groupby( + order.order_line.filtered(lambda line: not line.display_type), + lambda line: line.product_id + ): + if(product.id not in product_ids): + continue + if (len(lines) > 1) or lines[0].product_uom != product.uom_id: + price_unit = order.pricelist_id._get_product_price( + product=product, + quantity=1.0, + currency=order.currency_id, + date=order.date_order, + ) + sale_order_line_info[product.id] = dict( + readOnly=True, + price=price_unit, + ) + else: + line = lines[0] + sale_order_line_info[product.id] = dict( + quantity=line.product_uom_qty, + price=line.price_unit, + ) + product_ids.remove(product.id) + + sale_order_line_info.update({ + id: dict(price=price) for id, price in order.pricelist_id._get_products_price( + quantity=1.0, + products=request.env['product.product'].browse(product_ids), + currency=order.currency_id, + date=order.date_order, + ).items() + }) + return sale_order_line_info + + @route('/sales/catalog/update_sale_order_line_info', auth='user', type='json') + def sale_product_catalog_update_sale_order_line_info(self, order_id, product_id, quantity): + """ Update sale order line information on a given sale order for a given product. + + :param int order_id: The sale order, as a `sale.order` id. + :param int product_id: The product, as a `product.product` id. + :param float quantity: The quantity selected in the product catalog. + :return: The unit price price of the product, based on the pricelist of the sale order and + the quantity selected. + :rtype: float + """ + sol = request.env['sale.order.line'].search([ + ('order_id', '=', order_id), ('product_id', '=', product_id), + ]) + if sol: + if quantity != 0: + sol.product_uom_qty = quantity + elif sol.order_id.state in ['draft', 'sent']: + price_unit = sol.order_id.pricelist_id._get_product_price( + product=sol.product_id, + quantity=1.0, + currency=sol.order_id.currency_id, + date=sol.order_id.date_order, + ) + sol.unlink() + return price_unit + else: + sol.product_uom_qty = 0 + elif quantity > 0: + order = request.env['sale.order'].browse(order_id) + sol = request.env['sale.order.line'].create({ + 'order_id': order.id, + 'product_id': product_id, + 'product_uom_qty': quantity, + 'sequence': ((order.order_line and order.order_line[-1].sequence + 1) or 10), # put it at the end of the order + }) + return sol.price_unit diff --git a/addons/sale/models/product_product.py b/addons/sale/models/product_product.py index 917e230fc54..2593b4674c2 100644 --- a/addons/sale/models/product_product.py +++ b/addons/sale/models/product_product.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from datetime import timedelta, time -from odoo import fields, models, _, api +from odoo.exceptions import UserError +from odoo import api, fields, models, _ from odoo.tools.float_utils import float_round @@ -10,6 +11,12 @@ class ProductProduct(models.Model): sales_count = fields.Float(compute='_compute_sales_count', string='Sold', digits='Product Unit of Measure') + # Catalog related fields + product_catalog_product_is_in_sale_order = fields.Boolean( + compute='_compute_product_is_in_sale_order', + search='_search_product_is_in_sale_order', + ) + def _compute_sales_count(self): r = {} self.sales_count = 0 @@ -42,6 +49,30 @@ class ProductProduct(models.Model): 'message': _("You cannot change the product's type because it is already used in sales orders.") }} + @api.depends_context('order_id') + def _compute_product_is_in_sale_order(self): + order_id = self.env.context.get('order_id') + if not order_id: + self.product_catalog_product_is_in_sale_order = False + return + + read_group_data = self.env['sale.order.line']._read_group( + domain=[('order_id', '=', order_id)], + groupby=['product_id'], + aggregates=['__count'], + ) + data = {product.id: count for product, count in read_group_data} + for product in self: + product.product_catalog_product_is_in_sale_order = bool(data.get(product.id, 0)) + + def _search_product_is_in_sale_order(self, operator, value): + if operator not in ['=', '!='] or not isinstance(value, bool): + raise UserError(_("Operation not supported")) + product_ids = self.env['sale.order.line'].search([ + ('order_id', 'in', [self.env.context.get('order_id', '')]), + ]).product_id.ids + return [('id', 'in', product_ids)] + def action_view_sales(self): action = self.env["ir.actions.actions"]._for_xml_id("sale.report_all_channels_sales_action") action['domain'] = [('product_id', 'in', self.ids)] diff --git a/addons/sale/models/sale_order.py b/addons/sale/models/sale_order.py index 5c4a0ecb844..e9eb31b8623 100644 --- a/addons/sale/models/sale_order.py +++ b/addons/sale/models/sale_order.py @@ -1028,6 +1028,16 @@ class SaleOrder(models.Model): lines_to_recompute._compute_discount() self.show_update_pricelist = False + def _get_product_catalog_domain(self): + """Get the domain to search for products in the catalog. + + For a model that uses products that has to be hidden in the catalog, it must override this + method and extend the appropriate domain. + :returns: A list of tuples that represents a domain. + :rtype: list + """ + return [('sale_ok', '=', True), ('company_id', 'in', [self.company_id.id, False]),] + # INVOICING # def _prepare_invoice(self): diff --git a/addons/sale/models/sale_order_line.py b/addons/sale/models/sale_order_line.py index d260a326e4a..9720be8d499 100644 --- a/addons/sale/models/sale_order_line.py +++ b/addons/sale/models/sale_order_line.py @@ -1033,6 +1033,32 @@ class SaleOrderLine(models.Model): if self._check_line_unlink(): raise UserError(_("You can not remove an order line once the sales order is confirmed.\nYou should rather set the quantity to 0.")) + #=== ACTION METHODS ===# + + def action_add_from_catalog(self): + order = self.env['sale.order'].browse(self.env.context.get('order_id')) + kanban_view_id = self.env.ref('sale.sale_product_catalog_kanban_view').id + search_view_id = self.env.ref('sale.sale_product_catalog_search_view').id + + return { + 'type': 'ir.actions.act_window', + 'name': _('Products'), + 'res_model': 'product.product', + 'views': [(kanban_view_id, 'kanban'), (False, 'form')], + 'search_view_id': [search_view_id, 'search'], + 'domain': order._get_product_catalog_domain(), + 'context': { + **self.env.context, + **self._get_action_add_from_catalog_extra_context(order), + }, + 'help': _("""

+ Create a new product +

+ You must define a product for everything you sell or purchase, + whether it's a storable product, a consumable or a service. +

"""), + } + #=== BUSINESS METHODS ===# def _expected_date(self): @@ -1139,6 +1165,12 @@ class SaleOrderLine(models.Model): # True if the line is a computed line (reward, delivery, ...) that user cannot add manually return False + def _get_action_add_from_catalog_extra_context(self, order): + return dict( + product_catalog_order_id=order.id, + product_catalog_currency_id=order.currency_id.id, + ) + #=== TOOLING ===# def _convert_to_sol_currency(self, amount, currency): diff --git a/addons/sale/static/src/js/product_catalog/kanban_model.js b/addons/sale/static/src/js/product_catalog/kanban_model.js new file mode 100644 index 00000000000..b824b845654 --- /dev/null +++ b/addons/sale/static/src/js/product_catalog/kanban_model.js @@ -0,0 +1,25 @@ +/** @odoo-module */ +import { KanbanModel, KanbanDynamicRecordList } from "@web/views/kanban/kanban_model"; + +export class ProductCatalogKanbanDynamicRecordList extends KanbanDynamicRecordList { + + async load(params = {}) { + await super.load(...arguments); + await this._loadCatalogData(); + } + + async _loadCatalogData() { + const saleOrderLinesInfo = await this.model.rpc("/sales/catalog/sale_order_lines_info", { + order_id: this.context.order_id, + product_ids: this.records.map((rec) => rec.resId), + }); + + for (const record of this.records) { + record.productCatalogData = saleOrderLinesInfo[record.resId]; + } + } +} + +export class ProductCatalogKanbanModel extends KanbanModel {} + +ProductCatalogKanbanModel.DynamicRecordList = ProductCatalogKanbanDynamicRecordList; diff --git a/addons/sale/static/src/js/product_catalog/kanban_record.js b/addons/sale/static/src/js/product_catalog/kanban_record.js new file mode 100644 index 00000000000..85971172e32 --- /dev/null +++ b/addons/sale/static/src/js/product_catalog/kanban_record.js @@ -0,0 +1,110 @@ +/** @odoo-module */ +import { useState, useSubEnv } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; +import { KanbanRecord } from "@web/views/kanban/kanban_record"; +import { useDebounced } from "@web/core/utils/timing"; +import { ProductCatalogSOL } from "./sale_order_line/sale_order_line" + +export class ProductCatalogKanbanRecord extends KanbanRecord { + static template = "sale.ProductCatalogKanbanRecord"; + static components = {...KanbanRecord.components, ProductCatalogSOL}; + + setup() { + super.setup(); + this.rpc = useService("rpc"); + this.state = useState({ + price: this.props.record.productCatalogData.price, + quantity: this.props.record.productCatalogData.quantity || 0, + }); + this.debouncedUpdateQuantity = useDebounced(this._updateQuantity, 500); + + useSubEnv({ + currencyId: this.props.record.context.product_catalog_currency_id, + orderId: this.props.record.context.product_catalog_order_id, + productId: this.record.id.raw_value, + addProduct: this.addProduct.bind(this), + removeProduct: this.removeProduct.bind(this), + increaseQuantity: this.increaseQuantity.bind(this), + setQuantity: this.setQuantity.bind(this), + decreaseQuantity: this.decreaseQuantity.bind(this), + }); + } + + onGlobalClick(ev) { + // avoid a concurrent update when clicking on the buttons (that are inside the record) + if (ev.target.closest(".o_sale_product_catalog_cancel_global_click")) { + return; + } + this.increaseQuantity(); + } + + //-------------------------------------------------------------------------- + // Data Exchanges + //-------------------------------------------------------------------------- + + async _updateQuantity() { + const price = await this.rpc("/sales/catalog/update_sale_order_line_info", { + order_id: this.env.orderId, + product_id: this.env.productId, + quantity: this.state.quantity, + }) + // Force a reload of the page to remove a product from the view in the case a filter is + // applied. + if (this.state.quantity === 0) { + this.props.record.model.load(); + } + this.state.price = parseFloat(price); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Add the product to the order + */ + addProduct() { + if (this.props.record.productCatalogData.readOnly) return; + this.state.quantity = 1; + this.debouncedUpdateQuantity(); + } + + /** + * Remove the product to the order + */ + removeProduct() { + if (this.props.record.productCatalogData.readOnly) return; + this.state.quantity = 0; + this.debouncedUpdateQuantity(); + } + + /** + * Increase the quantity of the product on the sale order line. + */ + increaseQuantity() { + if (this.props.record.productCatalogData.readOnly) return; + this.state.quantity += 1; + this.debouncedUpdateQuantity(); + } + + /** + * Set the quantity of the product on the sale order line. + * + * @param {Event} event + */ + setQuantity(event) { + if (this.props.record.productCatalogData.readOnly) return; + this.state.quantity = parseFloat(event.target.value); + this.debouncedUpdateQuantity(); + } + + /** + * Decrease the quantity of the product on the sale order line. + */ + decreaseQuantity() { + if (this.props.record.productCatalogData.readOnly) return; + this.state.quantity -= 1; + this.debouncedUpdateQuantity(); + } + +} diff --git a/addons/sale/static/src/js/product_catalog/kanban_record.xml b/addons/sale/static/src/js/product_catalog/kanban_record.xml new file mode 100644 index 00000000000..a32ab8da586 --- /dev/null +++ b/addons/sale/static/src/js/product_catalog/kanban_record.xml @@ -0,0 +1,22 @@ + + + +
+
+ + +
+ +
+
+
diff --git a/addons/sale/static/src/js/product_catalog/kanban_view.js b/addons/sale/static/src/js/product_catalog/kanban_view.js new file mode 100644 index 00000000000..689a7f9266a --- /dev/null +++ b/addons/sale/static/src/js/product_catalog/kanban_view.js @@ -0,0 +1,18 @@ +/** @odoo-module **/ +import { registry } from "@web/core/registry"; +import { KanbanRenderer } from "@web/views/kanban/kanban_renderer"; +import { kanbanView } from "@web/views/kanban/kanban_view"; +import { ProductCatalogKanbanModel } from "./kanban_model"; +import { ProductCatalogKanbanRecord } from "./kanban_record"; + +export class ProductCatalogKanbanRenderer extends KanbanRenderer { + static components = {...KanbanRenderer.components, KanbanRecord: ProductCatalogKanbanRecord}; +} + +export const productCatalogKanbanView = { + ...kanbanView, + Model: ProductCatalogKanbanModel, + Renderer: ProductCatalogKanbanRenderer, +}; + +registry.category("views").add("sale_product_kanban", productCatalogKanbanView); diff --git a/addons/sale/static/src/js/product_catalog/sale_order_line/sale_order_line.js b/addons/sale/static/src/js/product_catalog/sale_order_line/sale_order_line.js new file mode 100644 index 00000000000..748c3b2607a --- /dev/null +++ b/addons/sale/static/src/js/product_catalog/sale_order_line/sale_order_line.js @@ -0,0 +1,26 @@ +/** @odoo-module */ +import { Component } from "@odoo/owl"; +import { formatMonetary } from "@web/views/fields/formatters"; + +export class ProductCatalogSOL extends Component { + static template = "sale.ProductCatalogSOL"; + static props = { + productId: Number, + quantity: Number, + price: Number, + readOnly: { type: Boolean, optional: true }, + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + isInSaleOrder() { + return this.props.quantity !== 0; + } + + get price() { + return formatMonetary(this.props.price, { currencyId: this.env.currencyId }); + } + +} diff --git a/addons/sale/static/src/js/product_catalog/sale_order_line/sale_order_line.scss b/addons/sale/static/src/js/product_catalog/sale_order_line/sale_order_line.scss new file mode 100644 index 00000000000..dd371b4b6d9 --- /dev/null +++ b/addons/sale/static/src/js/product_catalog/sale_order_line/sale_order_line.scss @@ -0,0 +1,11 @@ +div.o_sale_product_catalog_quantity { + input[type="number"] { + // Remove arrow buttons input type="number" + appearance: textfield; + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + appearance: none; + margin: 0; + } + } +} diff --git a/addons/sale/static/src/js/product_catalog/sale_order_line/sale_order_line.xml b/addons/sale/static/src/js/product_catalog/sale_order_line/sale_order_line.xml new file mode 100644 index 00000000000..e662975490e --- /dev/null +++ b/addons/sale/static/src/js/product_catalog/sale_order_line/sale_order_line.xml @@ -0,0 +1,44 @@ + + + + + + Unit price: + + + You can't edit this product in the catalog. + +
+
+ + + +
+
+ + +
+
+
+
diff --git a/addons/sale/views/product_product_views.xml b/addons/sale/views/product_product_views.xml new file mode 100644 index 00000000000..14743520d40 --- /dev/null +++ b/addons/sale/views/product_product_views.xml @@ -0,0 +1,96 @@ + + + + sale.product.catalog.kanban.view + product.product + + + + + + + Edit + + +
+
+ Product +
+
+
+
+ +

+ +

+
+
+ [] +
+ +
+ +
+
+
+ + + + + + + + sale.product.catalog.search.view + product.product + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/sale/views/sale_order_views.xml b/addons/sale/views/sale_order_views.xml index 4a6b7176732..cf375a502cb 100644 --- a/addons/sale/views/sale_order_views.xml +++ b/addons/sale/views/sale_order_views.xml @@ -430,6 +430,7 @@ +