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 @@ + +