[ADD] sale: add a products catalog view on the sale order

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) <vfe@odoo.com>
Co-authored-by: chevalierv <vcr@odoo.com>
This commit is contained in:
Horacio Tellez
2023-05-12 20:51:51 +02:00
committed by Valentin Chevalier
co-authored by chevalierv
parent 19e2e15027
commit 216fa84fbe
21 changed files with 602 additions and 1 deletions
+3
View File
@@ -0,0 +1,3 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import catalog
+24
View File
@@ -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
+10
View File
@@ -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')]])
+2
View File
@@ -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': [
+1
View File
@@ -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
+98
View File
@@ -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
+32 -1
View File
@@ -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)]
+10
View File
@@ -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):
+32
View File
@@ -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': _("""<p class="o_view_nocontent_smiling_face">
Create a new product
</p><p>
You must define a product for everything you sell or purchase,
whether it's a storable product, a consumable or a service.
</p>"""),
}
#=== 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):
@@ -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;
@@ -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();
}
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="sale.ProductCatalogKanbanRecord" owl="1">
<div role="article"
t-att-class="getRecordClasses()"
t-att-data-id="props.record.id"
t-att-tabindex="props.record.model.useSampleModel ? -1 : 0"
t-on-click="onGlobalClick"
t-ref="root">
<div class="d-flex flex-column h-100"
t-att-class="{'text-bg-primary': state.quantity || props.record.productCatalogData.readOnly}">
<t t-call="{{ templates[this.constructor.KANBAN_BOX_ATTRIBUTE] }}"
t-call-context="this.renderingContext"/>
<ProductCatalogSOL productId="record.id.raw_value"
quantity="state.quantity"
price="state.price"
readOnly="props.record.productCatalogData.readOnly"/>
</div>
<t t-call="{{ this.constructor.menuTemplate }}"/>
</div>
</t>
</templates>
@@ -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);
@@ -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 });
}
}
@@ -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;
}
}
}
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="sale.ProductCatalogSOL" owl="1">
<!-- Replace the element found using the css selector by the content of the portalled
template. -->
<t t-portal="'#product-'+props.productId">
<span>Unit price: <t t-out="price"/></span>
</t>
<span t-if="props.readOnly" class="my-2 pt-3 border-top">
You can't edit this product in the catalog.
</span>
<div t-else="" class="my-2 pt-3 border-top d-flex justify-content-end">
<div t-if="isInSaleOrder()"
class="input-group o_sale_product_catalog_quantity o_sale_product_catalog_cancel_global_click w-50">
<button class="btn btn-outline-secondary"
t-on-click="this.env.decreaseQuantity">
<i class="fa fa-minus center"/>
</button>
<input class="o_input form-control border-0 text-center text-bg-light"
type="number"
t-att-value="props.quantity"
t-on-change="this.env.setQuantity"/>
<button class="btn btn-outline-secondary"
t-on-click="this.env.increaseQuantity">
<i class="fa fa-plus"/>
</button>
</div>
<div class="ms-auto o_sale_product_catalog_cancel_global_click">
<button t-if="!isInSaleOrder()"
t-on-click="this.env.addProduct"
class="btn btn-primary rounded">
<i class="fa fa-shopping-cart"/>
Add
</button>
<button t-else=""
t-on-click="this.env.removeProduct"
class="btn btn-light rounded">
<i class="fa fa-trash"/>
Remove
</button>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="sale_product_catalog_kanban_view" model="ir.ui.view">
<field name="name">sale.product.catalog.kanban.view</field>
<field name="model">product.product</field>
<field name="arch" type="xml">
<kanban
records_draggable="0"
js_class="sale_product_kanban">
<field name="id" invisible="1"/>
<field name="default_code" invisible="1"/>
<templates>
<t t-name="kanban-menu" groups="sales_team.group_sale_manager">
<a role="menuitem" type="edit" class="dropdown-item border-top-0">Edit</a>
</t>
<t t-name="kanban-box">
<div class="d-flex flex-grow-1">
<div class="o_kanban_image">
<img t-att-src="kanban_image('product.product', 'image_128', record.id.raw_value)"
alt="Product"/>
</div>
<div class="oe_kanban_details p-2 d-flex">
<div class="o_kanban_record_top flex-column m-0">
<div class="d-flex">
<field class="me-2" name="priority" widget="priority"/>
<h4 class="o_kanban_record_title text-reset">
<strong><field name="name"/></strong>
</h4>
</div>
<div t-if="record.default_code.value">
[<field name="default_code"/>]
</div>
<!-- Used by @web/sale/js/product_catalog/sale_order_line to
show the price using a t-portal. -->
<div name="o_kanban_price"
t-attf-id="product-{{record.id.raw_value}}"
class="d-flex flex-column"/>
<field name="product_template_attribute_value_ids"
widget="many2many_tags"
groups="product.group_product_variant"
options="{'color_field': 'color'}"/>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="sale_product_catalog_search_view" model="ir.ui.view">
<field name="name">sale.product.catalog.search.view</field>
<field name="model">product.product</field>
<field name="arch" type="xml">
<search string="Product">
<!-- Search field -->
<field name="name"
string="Product"
filter_domain="['|', '|', ('default_code', 'ilike', self), ('name', 'ilike', self), ('barcode', 'ilike', self)]"/>
<field name="categ_id" filter_domain="[('categ_id', 'child_of', raw_value)]"/>
<field name="product_template_attribute_value_ids"
groups="product.group_product_variant"/>
<field name="product_tmpl_id" string="Product Template"/>
<!-- Filter -->
<filter string="Favorites" name="favorites" domain="[('priority', '= ','1')]"/>
<separator/>
<filter string="Services" name="services" domain="[('type', '=', 'service')]"/>
<filter string="Products"
name="products"
domain="[('type', 'in', ['consu', 'product'])]"/>
<filter string="In the Order"
name="products_in_order"
domain="[('product_catalog_product_is_in_sale_order', '=', True)]"/>
<!-- Group By -->
<group expand="1" string="Group By">
<filter string="Product Type" name="type" context="{'group_by':'type'}"/>
<filter string="Product Category"
name="categ_id"
context="{'group_by':'categ_id'}"/>
</group>
<!-- searchpanel -->
<searchpanel>
<field name="categ_id"
string="Product Category"
icon="fa-th-list"
enable_counters="1"/>
<field name="product_template_attribute_value_ids"
string="Attributes"
icon="fa-th-list"
enable_counters="1"
select="multi"/>
</searchpanel>
</search>
</field>
</record>
</odoo>
+11
View File
@@ -430,6 +430,7 @@
<create name="add_product_control" string="Add a product"/>
<create name="add_section_control" string="Add a section" context="{'default_display_type': 'line_section'}"/>
<create name="add_note_control" string="Add a note" context="{'default_display_type': 'line_note'}"/>
<button name="action_add_from_catalog" string="Catalog" type="object" class="px-4 btn-link" context="{'order_id': parent.id}"/>
</control>
<field name="sequence" widget="handle" />
@@ -584,6 +585,16 @@
<field name="tax_id" invisible="1"/>
<field name="company_id" invisible="1"/>
<field name="tax_calculation_rounding_method" invisible="1"/>
<control>
<create name="add_product_control" string="Add a product"/>
<create name="add_section_control" string="Add a section" context="{'default_display_type': 'line_section'}"/>
<create name="add_note_control" string="Add a note" context="{'default_display_type': 'line_note'}"/>
<button name="action_add_from_catalog"
context="{'order_id': parent.id}"
string="Catalog"
type="object"
class="btn-secondary"/>
</control>
<templates>
<t t-name="kanban-box">
<div t-attf-class="oe_kanban_card oe_kanban_global_click ps-0 pe-0 {{ record.display_type.raw_value ? 'o_is_' + record.display_type.raw_value : '' }}">
+1
View File
@@ -30,6 +30,7 @@ Preferences
'views/sale_stock_portal_template.xml',
'views/stock_lot_views.xml',
'views/res_users_views.xml',
'views/sale_product_kanban_views.xml',
'report/sale_order_report_templates.xml',
'report/stock_report_deliveryslip.xml',
@@ -354,3 +354,8 @@ class SaleOrderLine(models.Model):
if line_products.mapped('qty_delivered') and float_compare(values['product_uom_qty'], max(line_products.mapped('qty_delivered')), precision_digits=precision) == -1:
raise UserError(_('The ordered quantity cannot be decreased below the amount already delivered. Instead, create a return in your inventory.'))
super(SaleOrderLine, self)._update_line_quantity(values)
def _get_action_add_from_catalog_extra_context(self, order):
extra_context = super()._get_action_add_from_catalog_extra_context(order)
extra_context.update(warehouse=order.warehouse_id.id)
return extra_context
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="sale_product_catalog_kanban_view_inherit" model="ir.ui.view">
<field name="name">sale.product.catalog.kanban.view.inherit</field>
<field name="model">product.product</field>
<field name="inherit_id" ref="sale.sale_product_catalog_kanban_view"/>
<field name="arch" type="xml">
<field name="default_code" invisible="1" position="after">
<field name="detailed_type" invisible="1"/>
</field>
<div name="o_kanban_price" position="after">
<div t-if="['consu', 'product'].includes(record.detailed_type.raw_value)">
<span>On Hand: </span>
<field name="qty_available"/>
</div>
</div>
</field>
</record>
</odoo>