[IMP] product,purchase,sale,*: product catalog

This commit makes two major things:

1. Move the product catalog from `sale` to `product`.
It moves the product catalog's code from `sale` to `product` module in
order to be able to use it in other modules.
A mixin, `product.catalog.mixin`, was created for to make other models
catalog compatible.

2. It enables the product's catalog in `purchase`.
In `purchase`, the catalog differs a little bit from `sale`:
- If Unit of Measure is enable, the UoM for each product will appear in
  the catalog;
- If the product purchase's UoM or the purchase order line's UoM is
  different than the default product's UoM, the former one will be
  displayed (in bold so the user can know it's the UoM to refer);
- When opening the product's catalog from a purchase order, the products
  will be filtered by the PO's vendor;
- Some data from the vendor list will be used if appliant: the minimum
  quantity and the price;
- If there is at least one product's packaging, a button will be
  displayed to increase the qty by the packaging's one. If a packaging
  is set on the purchase order line, this one will be used.

task-3373589

Part-of: odoo/odoo#135502
This commit is contained in:
svs-odoo
2023-10-26 07:22:56 +00:00
committed by Arnold Moyaux
parent 0a80dbd643
commit bc01c7bcec
47 changed files with 850 additions and 367 deletions
-1
View File
@@ -1,5 +1,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import controllers
from . import models
from . import wizard
-3
View File
@@ -1,3 +0,0 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import catalog
-28
View File
@@ -1,28 +0,0 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.http import request, route
from odoo.addons.sale.controllers.catalog import CatalogController
class CatalogControllerDelivery(CatalogController):
@route()
def sale_product_catalog_update_sale_order_line_info(
self, order_id, product_id, quantity, **kwargs
):
""" 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, **kwargs
)
order = request.env['sale.order'].browse(order_id)
if order:
order.onchange_order_line()
return price_unit
+13
View File
@@ -161,3 +161,16 @@ class SaleOrder(models.Model):
for order_line in self.order_line.filtered(lambda l: l.product_id.type in ['product', 'consu'] and not l.is_delivery and not l.display_type and l.product_uom_qty > 0):
weight += order_line.product_qty * order_line.product_id.weight
return weight
def _update_order_line_info(self, product_id, quantity, **kwargs):
""" Override of `sale` to recompute the delivery prices.
:param int product_id: The product, as a `product.product` id.
: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()._update_order_line_info(product_id, quantity, **kwargs)
if self:
self.onchange_order_line()
return price_unit
+3
View File
@@ -61,6 +61,9 @@ Print product labels with barcode.
'assets': {
'web.assets_backend': [
'product/static/src/js/**/*',
'product/static/src/product_catalog/**/*.js',
'product/static/src/product_catalog/**/*.xml',
'product/static/src/product_catalog/**/*.scss',
],
'web.report_assets_common': [
'product/static/src/scss/report_label_sheet.scss',
+1
View File
@@ -1 +1,2 @@
from . import catalog
from . import product_document
+42
View File
@@ -0,0 +1,42 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.http import request, route, Controller
class ProductCatalogController(Controller):
@route('/product/catalog/order_lines_info', auth='user', type='json')
def product_catalog_get_order_lines_info(self, res_model, order_id, product_ids, **kwargs):
""" Returns products information to be shown in the catalog.
:param string res_model: The order model.
:param int order_id: The 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:
{
product.id: {
'productId': int
'quantity': float (optional)
'price': float
'readOnly': bool (optional)
}
}
"""
order = request.env[res_model].browse(order_id)
return order._get_product_catalog_order_line_info(product_ids, **kwargs)
@route('/product/catalog/update_order_line_info', auth='user', type='json')
def product_catalog_update_order_line_info(self, res_model, order_id, product_id, quantity=0, **kwargs):
""" Update order line information on a given order for a given product.
:param string res_model: The order model.
:param int order_id: The order id.
:param int product_id: The product, as a `product.product` id.
:return: The unit price price of the product, based on the pricelist of the order and
the quantity selected.
:rtype: float
"""
order = request.env[res_model].browse(order_id)
return order._update_order_line_info(product_id, quantity, **kwargs)
+1
View File
@@ -13,6 +13,7 @@ from . import ir_attachment
from . import product_attribute
from . import product_attribute_custom_value
from . import product_attribute_value
from . import product_catalog_mixin
from . import product_category
from . import product_document
from . import product_packaging
@@ -0,0 +1,124 @@
from odoo import _, models
class ProductCatalogMixin(models.AbstractModel):
""" This mixin should be inherited when the model should be able to work
with the product catalog.
It assumes the model using this mixin has a O2M field where the products are added/removed and
this field's co-related model should has a method named `_get_product_catalog_lines_data`.
"""
_name = 'product.catalog.mixin'
_description = 'Product Catalog Mixin'
def action_add_from_catalog(self):
kanban_view_id = self.env.ref('product.product_view_kanban_catalog').id
search_view_id = self.env.ref('product.product_view_search_catalog').id
additional_context = self._get_action_add_from_catalog_extra_context()
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': self._get_product_catalog_domain(),
'context': {**self.env.context, **additional_context},
}
def _default_order_line_values(self):
return {
'quantity': 0,
'readOnly': self._is_readonly() if self else 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 [('company_id', 'in', [self.company_id.id, False])]
def _get_product_catalog_record_lines(self, product_ids):
""" Returns the record's lines grouped by product.
Must be overrided by each model using this mixin.
:param list product_ids: The ids of the products currently displayed in the product catalog.
:rtype: dict
"""
return {}
def _get_product_catalog_order_data(self, products, **kwargs):
""" Returns a dict containing the products' data. Those data are for products who aren't in
the record yet. For products already in the record, see `_get_product_catalog_lines_data`.
For each product, its id is the key and the value is another dict with all needed data.
By default, the price is the only needed data but each model is free to add more data.
Must be overrided by each model using this mixin.
:param products: Recordset of `product.product`.
:param dict kwargs: additional values given for inherited models.
:rtype: dict
:return: A dict with the following structure:
{
'productId': int
'quantity': float (optional)
'price': float
'readOnly': bool (optional)
}
"""
return {}
def _get_product_catalog_order_line_info(self, product_ids, **kwargs):
""" Returns products information to be shown in the catalog.
:param list product_ids: The products currently displayed in the product catalog, as a list
of `product.product` ids.
:param dict kwargs: additional values given for inherited models.
:rtype: dict
:return: A dict with the following structure:
{
'productId': int
'quantity': float (optional)
'price': float
'readOnly': bool (optional)
}
"""
order_line_info = {}
default_data = self._default_order_line_values()
for product, record_lines in self._get_product_catalog_record_lines(product_ids).items():
order_line_info[product.id] = record_lines._get_product_catalog_lines_data(**kwargs)
product_ids.remove(product.id)
products = self.env['product.product'].browse(product_ids)
product_data = self._get_product_catalog_order_data(products, **kwargs)
for product_id, data in product_data.items():
order_line_info[product_id] = {**default_data, **data}
return order_line_info
def _get_action_add_from_catalog_extra_context(self):
return {
'product_catalog_order_id': self.id,
'product_catalog_order_model': self._name,
}
def _is_readonly(self):
""" Must be overrided by each model using this mixin.
:return: Whether the record is read-only or not.
:rtype: bool
"""
return False
def _update_order_line_info(self, product_id, quantity, **kwargs):
""" Update the line information for a given product or create a new one if none exists yet.
Must be overrided by each model using this mixin.
:param int product_id: The product, as a `product.product` id.
:param int quantity: The product's quantity.
:param dict kwargs: additional values given for inherited models.
:return: The unit price of the product, based on the pricelist of the
purchase order and the quantity selected.
:rtype: float
"""
return 0
+2 -2
View File
@@ -676,13 +676,13 @@ class ProductProduct(models.Model):
sellers |= seller
return sellers
def _select_seller(self, partner_id=False, quantity=0.0, date=None, uom_id=False, params=False):
def _select_seller(self, partner_id=False, quantity=0.0, date=None, uom_id=False, ordered_by='price', params=False):
sellers = self._get_filtered_sellers(partner_id=partner_id, quantity=quantity, date=date, uom_id=uom_id, params=params)
res = self.env['product.supplierinfo']
for seller in sellers:
if not res or res.partner_id == seller.partner_id:
res |= seller
return res and res.sorted('price')[:1]
return res and res.sorted(ordered_by)[:1]
def _get_product_price_context(self, combination):
self.ensure_one()
@@ -6,13 +6,14 @@ import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";
export class ProductCatalogKanbanController extends KanbanController {
static template = "sale.ProductCatalogKanbanController";
static template = "ProductCatalogKanbanController";
setup() {
super.setup();
this.action = useService("action");
this.orm = useService("orm");
this.orderId = this.props.context.order_id;
this.orderResModel = this.props.context.product_catalog_order_model;
onWillStart(async () => this._defineButtonContent());
}
@@ -23,9 +24,9 @@ export class ProductCatalogKanbanController extends KanbanController {
}
async _defineButtonContent() {
// Define the content of the button.
// Define the button's label depending of the order's state.
const orderStateInfo = await this.orm.searchRead(
"sale.order", [["id", "=", this.orderId]], ["state"]
this.orderResModel, [["id", "=", this.orderId]], ["state"]
);
const orderIsQuotation = ["draft", "sent"].includes(orderStateInfo[0].state);
if (orderIsQuotation) {
@@ -44,7 +45,7 @@ export class ProductCatalogKanbanController extends KanbanController {
} else {
await this.action.doAction({
type: "ir.actions.act_window",
res_model: "sale.order",
res_model: this.orderResModel,
views: [[false, "form"]],
view_mode: "form",
res_id: this.orderId,
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="sale.ProductCatalogKanbanController" t-inherit="web.KanbanView" t-inherit-mode="primary">
<t t-name="ProductCatalogKanbanController" t-inherit="web.KanbanView" t-inherit-mode="primary">
<xpath expr="//button[hasclass('o-kanban-button-new')]" position="replace">
<button t-out="this.buttonString" type="button" class="btn btn-secondary o-kanban-button-back" t-on-click="this.backToQuotation"/>
</xpath>
@@ -18,12 +18,13 @@ export class ProductCatalogKanbanModel extends RelationalModel {
async _loadData(params) {
const result = await super._loadData(...arguments);
if (!params.isMonoRecord && !params.groupBy.length) {
const saleOrderLinesInfo = await this.rpc("/sales/catalog/sale_order_lines_info", {
const orderLinesInfo = await this.rpc("/product/catalog/order_lines_info", {
order_id: params.context.order_id,
product_ids: result.records.map((rec) => rec.id),
res_model: params.context.product_catalog_order_model,
});
for (const record of result.records) {
record.productCatalogData = saleOrderLinesInfo[record.id];
record.productCatalogData = orderLinesInfo[record.id];
}
}
return result;
@@ -3,11 +3,14 @@ import { useSubEnv } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { useDebounced } from "@web/core/utils/timing";
import { KanbanRecord } from "@web/views/kanban/kanban_record";
import { ProductCatalogSOL } from "./sale_order_line/sale_order_line";
import { ProductCatalogOrderLine } from "./order_line/order_line";
export class ProductCatalogKanbanRecord extends KanbanRecord {
static template = "sale.ProductCatalogKanbanRecord";
static components = {...KanbanRecord.components, ProductCatalogSOL};
static template = "ProductCatalogKanbanRecord";
static components = {
...KanbanRecord.components,
ProductCatalogOrderLine,
};
setup() {
super.setup();
@@ -19,7 +22,10 @@ export class ProductCatalogKanbanRecord extends KanbanRecord {
useSubEnv({
currencyId: this.props.record.context.product_catalog_currency_id,
orderId: this.props.record.context.product_catalog_order_id,
orderResModel: this.props.record.context.product_catalog_order_model,
digits: this.props.record.context.product_catalog_digits,
displayUoM: this.props.record.context.display_uom,
precision: this.props.record.context.precision,
productId: this.props.record.resId,
addProduct: this.addProduct.bind(this),
removeProduct: this.removeProduct.bind(this),
@@ -29,16 +35,24 @@ export class ProductCatalogKanbanRecord extends KanbanRecord {
});
}
get orderLineComponent() {
return ProductCatalogOrderLine;
}
get productCatalogData() {
return this.props.record.productCatalogData;
}
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")) {
if (ev.target.closest(".o_product_catalog_cancel_global_click")) {
return;
}
this.increaseQuantity();
if (this.productCatalogData.quantity === 0) {
this.addProduct();
} else {
this.increaseQuantity();
}
}
//--------------------------------------------------------------------------
@@ -46,12 +60,17 @@ export class ProductCatalogKanbanRecord extends KanbanRecord {
//--------------------------------------------------------------------------
async _updateQuantity() {
const price = await this.rpc("/sales/catalog/update_sale_order_line_info", {
const price = await this._updateQuantityAndGetPrice();
this.productCatalogData.price = parseFloat(price);
}
_updateQuantityAndGetPrice() {
return this.rpc("/product/catalog/update_order_line_info", {
order_id: this.env.orderId,
product_id: this.env.productId,
quantity: this.productCatalogData.quantity,
res_model: this.env.orderResModel,
});
this.productCatalogData.price = parseFloat(price);
}
//--------------------------------------------------------------------------
@@ -65,11 +84,12 @@ export class ProductCatalogKanbanRecord extends KanbanRecord {
this.productCatalogData.quantity = quantity;
this.debouncedUpdateQuantity();
}
/**
* Add the product to the order
*/
addProduct() {
this.updateQuantity(1);
addProduct(qty=1) {
this.updateQuantity(qty);
}
/**
@@ -80,14 +100,14 @@ export class ProductCatalogKanbanRecord extends KanbanRecord {
}
/**
* Increase the quantity of the product on the sale order line.
* Increase the quantity of the product on the order line.
*/
increaseQuantity() {
this.updateQuantity(this.productCatalogData.quantity + 1);
increaseQuantity(qty=1) {
this.updateQuantity(this.productCatalogData.quantity + qty);
}
/**
* Set the quantity of the product on the sale order line.
* Set the quantity of the product on the order line.
*
* @param {Event} event
*/
@@ -96,7 +116,7 @@ export class ProductCatalogKanbanRecord extends KanbanRecord {
}
/**
* Decrease the quantity of the product on the sale order line.
* Decrease the quantity of the product on the order line.
*/
decreaseQuantity() {
this.updateQuantity(parseFloat(this.productCatalogData.quantity - 1));
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="sale.ProductCatalogKanbanRecord">
<t t-name="ProductCatalogKanbanRecord">
<div role="article"
t-att-class="getRecordClasses()"
t-att-data-id="props.record.id"
@@ -8,10 +8,10 @@
t-on-click="onGlobalClick"
t-ref="root">
<div class="d-flex flex-column h-100"
t-att-class="{'o_sale_product_added': productCatalogData.quantity}">
t-att-class="{'o_product_added': productCatalogData.quantity}">
<t t-call="{{ templates[this.constructor.KANBAN_BOX_ATTRIBUTE] }}"
t-call-context="this.renderingContext"/>
<ProductCatalogSOL productId="props.record.resId" t-props="productCatalogData"/>
<t t-component="orderLineComponent" productId="props.record.resId" t-props="productCatalogData"/>
</div>
<t t-call="{{ this.constructor.menuTemplate }}"/>
</div>
@@ -6,7 +6,7 @@ import { useService } from "@web/core/utils/hooks";
import { ProductCatalogKanbanRecord } from "./kanban_record";
export class ProductCatalogKanbanRenderer extends KanbanRenderer {
static template = "sale.ProductCatalogKanbanRenderer";
static template = "ProductCatalogKanbanRenderer";
static components = {
...KanbanRenderer.components,
KanbanRecord: ProductCatalogKanbanRecord,
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="sale.ProductCatalogKanbanRenderer" t-inherit="web.KanbanRenderer" t-inherit-mode="primary">
<t t-name="ProductCatalogKanbanRenderer" t-inherit="web.KanbanRenderer" t-inherit-mode="primary">
<t t-if="showNoContentHelper" position="replace">
<t t-if="showNoContentHelper">
<div class="o_view_nocontent" role="alert">
<div class="o_nocontent_help">
<p class="o_view_nocontent_smiling_face">
No products could be found.<br/>
<button class="mt-2 btn btn-primary" t-on-click="this.createProduct">Create a product</button>
<button class="mt-2 btn btn-primary" t-on-click="this.createProduct">Create a product</button>
</p>
<p>
You must define a product for everything you sell or purchase,
@@ -17,4 +17,4 @@ export const productCatalogKanbanView = {
SearchPanel: ProductCatalogSearchPanel,
};
registry.category("views").add("sale_product_kanban", productCatalogKanbanView);
registry.category("views").add("product_kanban_catalog", productCatalogKanbanView);
@@ -1,9 +1,9 @@
/** @odoo-module */
import { Component } from "@odoo/owl";
import { formatMonetary } from "@web/views/fields/formatters";
import { formatFloat, formatMonetary } from "@web/views/fields/formatters";
export class ProductCatalogSOL extends Component {
static template = "sale.ProductCatalogSOL";
export class ProductCatalogOrderLine extends Component {
static template = "ProductCatalogOrderLine";
static props = {
productId: Number,
quantity: Number,
@@ -15,7 +15,7 @@ export class ProductCatalogSOL extends Component {
// Private
//--------------------------------------------------------------------------
isInSaleOrder() {
isInOrder() {
return this.props.quantity !== 0;
}
@@ -28,7 +28,13 @@ export class ProductCatalogSOL extends Component {
}
get price() {
return formatMonetary(this.props.price, { currencyId: this.env.currencyId, digits: this.env.digits });
const { currencyId, digits } = this.env;
return formatMonetary(this.props.price, { currencyId, digits });
}
get quantity() {
const digits = [false, this.env.precision];
const options = { digits, decimalPoint: ".", thousandsSep: "" };
return parseFloat(formatFloat(this.props.quantity, options));
}
}
@@ -1,4 +1,4 @@
div.o_sale_product_catalog_quantity {
div.o_product_catalog_quantity {
input[type="number"] {
// Remove arrow buttons input type="number"
appearance: textfield;
@@ -10,7 +10,7 @@ div.o_sale_product_catalog_quantity {
}
}
.o_sale_product_kanban_view .o_kanban_renderer .o_kanban_record .o_sale_product_added {
.o_product_kanban_view .o_kanban_renderer .o_kanban_record .o_product_added {
background-color: $o-component-active-bg;
border-color: $o-component-active-border;
}
@@ -1,41 +1,41 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="sale.ProductCatalogSOL">
<t t-name="ProductCatalogOrderLine">
<!-- 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 t-portal="`#product-${props.productId}-price`">
<span class="o_product_catalog_price">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="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">
<div t-if="isInOrder()"
class="input-group o_product_catalog_quantity o_product_catalog_cancel_global_click w-50">
<button class="btn btn-primary border"
t-on-click="this.env.decreaseQuantity"
t-on-click.stop="this.env.decreaseQuantity"
t-att-disabled="disableRemove"
t-att-data-tooltip="disabledButtonTooltip">
<i class="fa fa-minus center"/>
</button>
<input class="o_input form-control border text-center text-bg-light"
type="number"
t-att-value="props.quantity"
t-att-value="quantity"
t-on-change="this.env.setQuantity"/>
<button class="btn btn-primary border"
t-on-click="this.env.increaseQuantity">
t-on-click.stop="(ev) => 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"
<div class="ms-auto o_product_catalog_buttons o_product_catalog_cancel_global_click">
<button t-if="!isInOrder()"
t-on-click.stop="() => this.env.addProduct()"
class="btn btn-secondary">
<i class="fa fa-shopping-cart"/>
Add
</button>
<div t-else="" class="o_tooltip_div_remove" t-att-data-tooltip="this.disabledButtonTooltip">
<button t-on-click="this.env.removeProduct"
<button t-on-click.stop="this.env.removeProduct"
t-att-disabled="disableRemove"
class="btn btn-light border">
<i class="fa fa-trash"/>
@@ -52,5 +52,5 @@ export class ProductCatalogSearchPanel extends SearchPanel {
ProductCatalogSearchPanel.subTemplates = {
...SearchPanel.subTemplates,
filtersGroup: "sale.ProductCatalogSearchPanel.FiltersGroup",
filtersGroup: "ProductCatalogSearchPanel.FiltersGroup",
}
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="sale.ProductCatalogSearchPanel.FiltersGroup" t-inherit="web.SearchPanel.FiltersGroup" t-inherit-mode="primary">
<t t-name="ProductCatalogSearchPanel.FiltersGroup" t-inherit="web.SearchPanel.FiltersGroup" t-inherit-mode="primary">
<li t-foreach="[...values.keys()]" position="replace">
<li t-foreach="[...this.state.sectionOfAttributes.keys()]" t-as="attrName" t-key="attrName"
class="o_search_panel_filter_value list-group-item p-0 mb-1 border-0 o_cursor_pointer"
+91
View File
@@ -532,4 +532,95 @@ action = {
</field>
</record>
<record id="product_view_kanban_catalog" model="ir.ui.view">
<field name="name">product.view.kanban.catalog</field>
<field name="model">product.product</field>
<field name="arch" type="xml">
<kanban records_draggable="0" js_class="product_kanban_catalog">
<field name="id" invisible="1"/>
<field name="default_code" invisible="1"/>
<templates>
<t t-name="kanban-menu">
<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"
t-attf-id="product-{{record.id.raw_value}}">
<div class="d-flex">
<field style="margin-top: 2px;" class="me-1" name="priority" widget="priority"/>
<h4 class="text-reset">
<strong class="o_kanban_record_title"><field name="name"/></strong>
</h4>
</div>
<div t-if="record.default_code.value">
[<field name="default_code"/>]
</div>
<!-- Used by @web/product/js/product_catalog/order_line to
show the price using a t-portal. -->
<div name="o_kanban_price"
t-attf-id="product-{{record.id.raw_value}}-price"
class="d-flex flex-column"/>
<field name="product_template_attribute_value_ids"
widget="many2many_tags"
domain="[('id', 'in', parent.ids)]"
groups="product.group_product_variant"
options="{'color_field': 'color'}"/>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="product_view_search_catalog" model="ir.ui.view">
<field name="name">product.view.search.catalog</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'])]"/>
<!-- 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>
+1
View File
@@ -38,6 +38,7 @@
'application': True,
'assets': {
'web.assets_backend': [
'purchase/static/src/product_catalog/**/*',
'purchase/static/src/toaster_button/*',
'purchase/static/src/views/*.js',
'purchase/static/src/js/tours/purchase.js',
+133 -1
View File
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from datetime import datetime
from dateutil.relativedelta import relativedelta
@@ -7,6 +8,7 @@ from markupsafe import escape, Markup
from werkzeug.urls import url_encode
from odoo import api, fields, models, _
from odoo.osv import expression
from odoo.tools import format_amount, format_date, formatLang, groupby
from odoo.tools.float_utils import float_is_zero
from odoo.exceptions import UserError, ValidationError
@@ -14,7 +16,7 @@ from odoo.exceptions import UserError, ValidationError
class PurchaseOrder(models.Model):
_name = "purchase.order"
_inherit = ['portal.mixin', 'mail.thread', 'mail.activity.mixin']
_inherit = ['portal.mixin', 'product.catalog.mixin', 'mail.thread', 'mail.activity.mixin']
_description = "Purchase Order"
_rec_names_search = ['name', 'partner_ref']
_order = 'priority desc, id desc'
@@ -814,6 +816,85 @@ class PurchaseOrder(models.Model):
('mail_reminder_confirmed', '=', False)
]).filtered(lambda p: p.mapped('order_line.product_id.product_tmpl_id.type') != ['service'])
def _default_order_line_values(self):
default_data = super()._default_order_line_values()
new_default_data = self.env['purchase.order.line']._get_product_catalog_lines_data()
return {**default_data, **new_default_data}
def _get_action_add_from_catalog_extra_context(self):
return {
**super()._get_action_add_from_catalog_extra_context(),
'display_uom': self.env.user.has_group('uom.group_uom'),
'precision': self.env['decimal.precision'].precision_get('Product Unit of Measure'),
'product_catalog_currency_id': self.currency_id.id,
'product_catalog_digits': self.order_line._fields['price_unit'].get_digits(self.env),
'search_default_seller_ids': self.partner_id.name,
}
def _get_product_catalog_domain(self):
return expression.AND([super()._get_product_catalog_domain(), [('purchase_ok', '=', True)]])
def _get_product_catalog_order_data(self, products, **kwargs):
return {product.id: self._get_product_price_and_data(product) for product in products}
def _get_product_catalog_record_lines(self, product_ids):
grouped_lines = defaultdict(lambda: self.env['purchase.order.line'])
for line in self.order_line:
if line.display_type or line.product_id.id not in product_ids:
continue
grouped_lines[line.product_id] |= line
return grouped_lines
def _get_product_price_and_data(self, product):
""" Fetch the product's data used by the purchase's catalog.
:return: the product's price and, if applicable, the minimum quantity to
buy and the product's packaging data.
:rtype: dict
"""
self.ensure_one()
product_infos = {
'price': product.standard_price,
'uom': {
'display_name': product.uom_id.display_name,
'id': product.uom_id.id,
},
}
if product.uom_id != product.uom_po_id:
product_infos['purchase_uom'] = {
'display_name': product.uom_po_id.display_name,
'id': product.uom_po_id.id,
}
params = {'order_id': self}
# Check if there is a price and a minimum quantity for the order's vendor.
seller = product._select_seller(
partner_id=self.partner_id,
quantity=None,
date=self.date_order and self.date_order.date(),
uom_id=product.uom_id,
ordered_by='min_qty',
params=params
)
if seller:
product_infos.update(
price=seller.price,
min_qty=seller.min_qty,
)
# Check if the product uses some packaging.
packaging = self.env['product.packaging'].search(
[('product_id', '=', product.id), ('purchase', '=', True)], limit=1
)
if packaging:
qty = packaging.product_uom_id._compute_quantity(packaging.qty, product.uom_po_id)
product_infos.update(
packaging={
'id': packaging.id,
'name': packaging.display_name,
'qty': qty,
}
)
return product_infos
def get_confirm_url(self, confirm_type=None):
"""Create url for confirm reminder or purchase reception email for sending
in mail."""
@@ -871,6 +952,47 @@ class PurchaseOrder(models.Model):
for line, date in updated_dates:
line._update_date_planned(date)
def _update_order_line_info(self, product_id, quantity, **kwargs):
""" Update purchase order line information for a given product or create
a new one if none exists yet.
:param int product_id: The product, as a `product.product` id.
:return: The unit price of the product, based on the pricelist of the
purchase order and the quantity selected.
:rtype: float
"""
self.ensure_one()
product_packaging_qty = kwargs.get('product_packaging_qty', False)
product_packaging_id = kwargs.get('product_packaging_id', False)
pol = self.order_line.filtered(lambda line: line.product_id.id == product_id)
if pol:
if product_packaging_qty:
pol.product_packaging_id = product_packaging_id
pol.product_packaging_qty = product_packaging_qty
elif quantity != 0:
pol.product_qty = quantity
elif self.state in ['draft', 'sent']:
price_unit = self._get_product_price_and_data(pol.product_id)['price']
pol.unlink()
return price_unit
else:
pol.product_qty = 0
elif quantity > 0:
pol = self.env['purchase.order.line'].create({
'order_id': self.id,
'product_id': product_id,
'product_qty': quantity,
'sequence': ((self.order_line and self.order_line[-1].sequence + 1) or 10), # put it at the end of the order
})
seller = pol.product_id._select_seller(
partner_id=pol.partner_id,
quantity=pol.product_qty,
date=pol.order_id.date_order and pol.order_id.date_order.date() or fields.Date.context_today(pol),
uom_id=pol.product_uom)
if seller:
# Fix the PO line's price on the seller's one.
pol.price_unit = seller.price
return pol.price_unit
def _create_update_date_activity(self, updated_dates):
note = Markup('<p>%s</p>\n') % _('%s modified receipt dates for the following products:', self.partner_id.name)
for line, date in updated_dates:
@@ -907,3 +1029,13 @@ class PurchaseOrder(models.Model):
if 'reminder_date_before_receipt' in vals:
partner_values['reminder_date_before_receipt'] = vals.pop('reminder_date_before_receipt')
return vals, partner_values
def _is_readonly(self):
""" Return whether the purchase order is read-only or not based on the state.
A purchase order is considered read-only if its state is 'cancel'.
:return: Whether the purchase order is read-only or not.
:rtype: bool
"""
self.ensure_one()
return self.state == 'cancel'
@@ -433,6 +433,10 @@ class PurchaseOrderLine(models.Model):
price_unit *= self.product_uom.factor / self.product_id.uom_id.factor
return price_unit
def action_add_from_catalog(self):
order = self.env['purchase.order'].browse(self.env.context.get('order_id'))
return order.action_add_from_catalog()
def action_purchase_history(self):
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id("purchase.action_purchase_history")
@@ -459,6 +463,70 @@ class PurchaseOrderLine(models.Model):
else:
self.product_qty = 1.0
def _get_product_catalog_lines_data(self):
""" Return information about purchase order lines in `self`.
If `self` is empty, this method returns only the default value(s) needed for the product
catalog. In this case, the quantity that equals 0.
Otherwise, it returns a quantity and a price based on the product of the POL(s) and whether
the product is read-only or not.
A product is considered read-only if the order is considered read-only (see
``PurchaseOrder._is_readonly`` for more details) or if `self` contains multiple records.
Note: This method cannot be called with multiple records that have different products linked.
:raise odoo.exceptions.ValueError: ``len(self.product_id) != 1``
:rtype: dict
:return: A dict with the following structure:
{
'quantity': float,
'price': float,
'readOnly': bool,
'uom': dict,
'purchase_uom': dict,
'packaging': dict,
}
"""
if len(self) == 1:
catalog_info = self.order_id._get_product_price_and_data(self.product_id)
uom = {
'display_name': self.product_id.uom_id.display_name,
'id': self.product_id.uom_id.id,
}
catalog_info.update(
quantity=self.product_qty,
price=self.price_unit,
readOnly=self.order_id._is_readonly(),
uom=uom,
)
if self.product_id.uom_id != self.product_uom:
catalog_info['purchase_uom'] = {
'display_name': self.product_uom.display_name,
'id': self.product_uom.id,
}
if self.product_packaging_id:
packaging = self.product_packaging_id
catalog_info['packaging'] = {
'id': packaging.id,
'name': packaging.display_name,
'qty': packaging.product_uom_id._compute_quantity(packaging.qty, self.product_uom),
}
return catalog_info
elif self:
self.product_id.ensure_one()
order_line = self[0]
catalog_info = order_line.order_id._get_product_price_and_data(order_line.product_id)
catalog_info['quantity'] = sum(self.mapped(
lambda line: line.product_uom._compute_quantity(
qty=line.product_qty,
to_unit=line.product_id.uom_id,
)))
catalog_info['readOnly'] = True
return catalog_info
return {'quantity': 0}
def _get_product_purchase_description(self, product_lang):
self.ensure_one()
name = product_lang.display_name
@@ -0,0 +1,45 @@
/** @odoo-module */
import { ProductCatalogKanbanRecord } from "@product/product_catalog/kanban_record";
import { ProductCatalogPurchaseOrderLine } from "./purchase_order_line/purchase_order_line";
import { patch } from "@web/core/utils/patch";
import { useService } from "@web/core/utils/hooks";
import { useSubEnv } from "@odoo/owl";
patch(ProductCatalogKanbanRecord.prototype, {
setup() {
super.setup();
this.orm = useService("orm");
useSubEnv({
updatePackagingQuantity: this.updatePackagingQuantity.bind(this),
});
},
get orderLineComponent() {
if (this.env.orderResModel === "purchase.order") {
return ProductCatalogPurchaseOrderLine;
}
return super.orderLineComponent;
},
addProduct() {
if (this.productCatalogData.quantity === 0 && this.productCatalogData.min_qty) {
super.addProduct(this.productCatalogData.min_qty);
} else {
super.addProduct(...arguments);
}
},
async updatePackagingQuantity(packaging) {
const productPackagingQty =
Math.floor(this.productCatalogData.quantity / packaging.qty) + 1;
this.productCatalogData.quantity = productPackagingQty * packaging.qty;
const price = await this.rpc("/product/catalog/update_order_line_info", {
order_id: this.env.orderId,
product_id: this.env.productId,
product_packaging_id: packaging.id,
product_packaging_qty: productPackagingQty,
res_model: this.env.orderResModel,
});
this.productCatalogData.price = parseFloat(price);
},
});
@@ -0,0 +1,21 @@
/** @odoo-module */
import { ProductCatalogOrderLine } from "@product/product_catalog/order_line/order_line";
export class ProductCatalogPurchaseOrderLine extends ProductCatalogOrderLine {
static template = "ProductCatalogPurchaseOrderLine";
static props = {
...ProductCatalogPurchaseOrderLine.props,
min_qty: { type: Number, optional: true },
packaging: { type: Object, optional: true },
purchase_uom: { type: Object, optional: true },
uom: Object,
};
get highlightUoM() {
return true;
}
addPackagingQty() {
this.env.updatePackagingQuantity(this.props.packaging);
}
}
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="ProductCatalogPurchaseOrderLine"
t-inherit="product.ProductCatalogOrderLine"
t-inherit-mode="primary">
<xpath expr="//span[hasclass('o_product_catalog_price')]" position="attributes">
<attribute name="t-if">!this.env.displayUoM</attribute>
</xpath>
<xpath expr="//span[hasclass('o_product_catalog_price')]" position="after">
<span class="o_product_catalog_price" t-if="env.displayUoM">
Price: <t t-out="price"/>
/ <span t-att-class="{'fw-bold text-primary': props.purchase_uom}">
<t t-if="props.purchase_uom" t-esc="props.purchase_uom.display_name"/>
<t t-else="" t-esc="props.uom.display_name"/>
</span>
</span>
</xpath>
<xpath expr="//div[hasclass('o_product_catalog_quantity')]" position="inside">
<button t-if="props.packaging"
class="o_product_catalog_package_quantity btn btn-primary border"
t-on-click.stop="addPackagingQty">
<i class="fa fa-cube"/>
</button>
</xpath>
</t>
</templates>
+34 -10
View File
@@ -139,15 +139,39 @@
</field>
</record>
<record id="product_template_search_view_purchase" model="ir.ui.view">
<field name="name">product.template.search.purchase</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_search_view"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='categ_id']" position="after">
<field string="Vendor" name="seller_ids"/>
</xpath>
</field>
</record>
<record id="product_template_search_view_purchase" model="ir.ui.view">
<field name="name">product.template.search.purchase</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_search_view"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='categ_id']" position="after">
<field string="Vendor" name="seller_ids"/>
</xpath>
</field>
</record>
<!-- Product catalog -->
<record id="product_view_kanban_catalog_purchase_only" model="ir.ui.view">
<field name="name">product.view.kanban.catalog.purchase</field>
<field name="model">product.product</field>
<field name="inherit_id" ref="product.product_view_kanban_catalog"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<field name="default_code" position="after">
<field name="uom_po_id" invisible="1"/>
</field>
</field>
</record>
<record id="product_view_search_catalog" model="ir.ui.view">
<field name="name">product.view.search.catalog.inherit.purchase</field>
<field name="model">product.product</field>
<field name="inherit_id" ref="product.product_view_search_catalog"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='product_tmpl_id']" position="after">
<field name="seller_ids" string="Vendor"/>
</xpath>
</field>
</record>
</odoo>
+1
View File
@@ -218,6 +218,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="tax_calculation_rounding_method" column_invisible="True"/>
<field name="display_type" column_invisible="True"/>
@@ -102,6 +102,13 @@ class PurchaseOrder(models.Model):
# Actions
# --------------------------------------------------
def action_add_from_catalog(self):
# Replaces the product's kanban view by the purchase specific one.
action = super().action_add_from_catalog()
kanban_view_id = self.env.ref('purchase_stock.product_view_kanban_catalog_purchase_only').id
action['views'][0] = (kanban_view_id, 'kanban')
return action
def button_approve(self, force=False):
result = super(PurchaseOrder, self).button_approve(force=force)
self._create_picking()
@@ -23,4 +23,21 @@
</field>
</field>
</record>
<record id="product_view_kanban_catalog_purchase_only" model="ir.ui.view">
<field name="name">product.view.kanban.catalog.purchase_stock</field>
<field name="model">product.product</field>
<field name="inherit_id" ref="product.product_view_kanban_catalog"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<div name="o_kanban_qty_available" position="after">
<div t-if="record.detailed_type.raw_value == 'product'"
name="o_kanban_qty_forecasted">
<span>Forecasted: </span>
<field name="virtual_available"/>
<field name="uom_id" class="ms-1" groups="uom.group_uom"/>
</div>
</div>
</field>
</record>
</odoo>
-2
View File
@@ -50,7 +50,6 @@ This module contains all the common features of Sales Management and eCommerce.
'views/payment_views.xml',
'views/product_document_views.xml',
'views/product_packaging_views.xml',
'views/product_product_views.xml',
'views/product_views.xml',
'views/res_partner_views.xml',
'views/sale_order_line_views.xml',
@@ -70,7 +69,6 @@ This module contains all the common features of Sales Management and eCommerce.
'sale/static/src/js/sale_progressbar_field.js',
'sale/static/src/js/tours/sale.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,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import catalog
from . import portal
-97
View File
@@ -1,97 +0,0 @@
# 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, **kwargs):
""" 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
sale_order_line_info[product.id] = request.env['sale.order.line'].browse(
line.id for line in lines # groupby gives a list and not a recordset
)._get_catalog_info()
product_ids.remove(product.id)
default_data = request.env['sale.order.line']._get_catalog_info()
default_data['readOnly'] = order._is_readonly() if order else False
sale_order_line_info.update({
id: {
**default_data,
'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,
**kwargs,
).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, **kwargs
):
""" 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,
**kwargs,
)
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
+67 -10
View File
@@ -1,11 +1,12 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from datetime import timedelta
from itertools import groupby
from odoo import api, fields, models, SUPERUSER_ID, _
from odoo.exceptions import AccessError, UserError, ValidationError
from odoo.fields import Command
from odoo.osv import expression
from odoo.tools import float_is_zero, format_amount, format_date, html_keep_url, is_html_empty
from odoo.tools.sql import create_index
@@ -28,7 +29,7 @@ SALE_ORDER_STATE = [
class SaleOrder(models.Model):
_name = 'sale.order'
_inherit = ['portal.mixin', 'mail.thread', 'mail.activity.mixin', 'utm.mixin']
_inherit = ['portal.mixin', 'product.catalog.mixin', 'mail.thread', 'mail.activity.mixin', 'utm.mixin']
_description = "Sales Order"
_order = 'date_order desc, id desc'
_check_company_auto = True
@@ -1098,15 +1099,20 @@ 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.
def _default_order_line_values(self):
default_data = super()._default_order_line_values()
new_default_data = self.env['sale.order.line']._get_product_catalog_lines_data()
return {**default_data, **new_default_data}
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]),]
def _get_action_add_from_catalog_extra_context(self):
return {
**super()._get_action_add_from_catalog_extra_context(),
'product_catalog_currency_id': self.currency_id.id,
'product_catalog_digits': self.order_line._fields['price_unit'].get_digits(self.env),
}
def _get_product_catalog_domain(self):
return expression.AND([super()._get_product_catalog_domain(), [('sale_ok', '=', True)]])
# INVOICING #
@@ -1790,6 +1796,24 @@ class SaleOrder(models.Model):
return generated_invoices
def _get_product_catalog_order_data(self, products, **kwargs):
pricelist = self.pricelist_id._get_products_price(
quantity=1.0,
products=products,
currency=self.currency_id,
date=self.date_order,
**kwargs,
)
return {product_id: {'price': price} for product_id, price in pricelist.items()}
def _get_product_catalog_record_lines(self, product_ids):
grouped_lines = defaultdict(lambda: self.env['sale.order.line'])
for line in self.order_line:
if line.display_type or line.product_id.id not in product_ids:
continue
grouped_lines[line.product_id] |= line
return grouped_lines
def _get_product_documents(self):
self.ensure_one()
@@ -1806,6 +1830,39 @@ class SaleOrder(models.Model):
or (self.state == 'sale' and document.attached_on == 'sale_order')
)
def _update_order_line_info(self, product_id, quantity, **kwargs):
""" Update sale order line information for a given product or create a
new one if none exists yet.
:param int product_id: The product, as a `product.product` id.
:return: The unit price of the product, based on the pricelist of the
sale order and the quantity selected.
:rtype: float
"""
sol = self.order_line.filtered(lambda line: line.product_id.id == product_id)
if sol:
if quantity != 0:
sol.product_uom_qty = quantity
elif self.state in ['draft', 'sent']:
price_unit = self.pricelist_id._get_product_price(
product=sol.product_id,
quantity=1.0,
currency=self.currency_id,
date=self.date_order,
**kwargs,
)
sol.unlink()
return price_unit
else:
sol.product_uom_qty = 0
elif quantity > 0:
sol = self.env['sale.order.line'].create({
'order_id': self.id,
'product_id': product_id,
'product_uom_qty': quantity,
'sequence': ((self.order_line and self.order_line[-1].sequence + 1) or 10), # put it at the end of the order
})
return sol.price_unit
#=== HOOKS ===#
def add_option_to_order_with_taxcloud(self):
+3 -23
View File
@@ -1069,21 +1069,7 @@ class SaleOrderLine(models.Model):
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),
},
}
return order.action_add_from_catalog()
#=== BUSINESS METHODS ===#
@@ -1183,14 +1169,7 @@ 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,
product_catalog_digits=order.order_line._fields['price_unit'].get_digits(order.env),
)
def _get_catalog_info(self):
def _get_product_catalog_lines_data(self, **kwargs):
""" Return information about sale order lines in `self`.
If `self` is empty, this method returns only the default value(s) needed for the product
@@ -1230,6 +1209,7 @@ class SaleOrderLine(models.Model):
quantity=1.0,
currency=order.currency_id,
date=order.date_order,
**kwargs,
),
'quantity': sum(
self.mapped(
@@ -1,97 +0,0 @@
<?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 style="margin-top: 2px;" class="me-1" name="priority" widget="priority"/>
<h4 class="text-reset">
<strong class="o_kanban_record_title"><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"
domain="[('id', 'in', parent.ids)]"
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>
+25
View File
@@ -122,4 +122,29 @@
</field>
</record>
<record id="product_view_kanban_catalog" model="ir.ui.view">
<field name="name">product.view.kanban.catalog.inherit.sale</field>
<field name="inherit_id" ref="product.product_view_kanban_catalog"/>
<field name="model">product.product</field>
<field name="arch" type="xml">
<xpath expr="//t[@t-name='kanban-menu']" position="attributes">
<attribute name="groups">sales_team.group_sale_manager</attribute>
</xpath>
</field>
</record>
<record id="product_view_search_catalog" model="ir.ui.view">
<field name="name">product.view.search.catalog.inherit.sale</field>
<field name="inherit_id" ref="product.product_view_search_catalog"/>
<field name="model">product.product</field>
<field name="arch" type="xml">
<filter name="products" position="after">
<filter string="In the Order"
invisible="context.get('active_model', '') != 'sale.order.line'"
name="products_in_order"
domain="[('product_catalog_product_is_in_sale_order', '=', True)]"/>
</filter>
</field>
</record>
</odoo>
-1
View File
@@ -30,7 +30,6 @@ 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',
+2 -2
View File
@@ -344,7 +344,7 @@ class SaleOrderLine(models.Model):
extra_context.update(warehouse=order.warehouse_id.id)
return extra_context
def _get_catalog_info(self):
def _get_product_catalog_lines_data(self, **kwargs):
""" Override of `sale` to add the delivered quantity.
:rtype: dict
@@ -356,7 +356,7 @@ class SaleOrderLine(models.Model):
'readOnly': bool,
}
"""
res = super()._get_catalog_info()
res = super()._get_product_catalog_lines_data(**kwargs)
res['deliveredQty'] = sum(
self.mapped(
lambda line: line.product_uom._compute_quantity(
@@ -1,20 +1,30 @@
/** @odoo-module */
import { ProductCatalogKanbanRecord } from "@sale/js/product_catalog/kanban_record";
import { ProductCatalogKanbanRecord } from "@product/product_catalog/kanban_record";
import { ProductCatalogSaleOrderLine } from "./sale_order_line/sale_order_line";
import { patch } from "@web/core/utils/patch";
patch(ProductCatalogKanbanRecord.prototype, {
updateQuantity(quantity) {
if (
if (this.env.orderResModel !== "sale.order") {
super.updateQuantity(...arguments);
} else if (
this.productCatalogData.quantity === this.productCatalogData.deliveredQty &&
quantity < this.productCatalogData.quantity
) {
// This condition is triggered on the following specific condition that the product was already at the minimum quantity possible,
// as stated in the sale_stock module, then the user inputs a quantity lower than this limit, in this case we need the record to forcefully update
// the record
// This condition is only triggered when the product was already at the minimum quantity
// possible, as stated in the sale_stock module, then the user inputs a quantity lower
// than this limit, in this case we need the record to forcefully update the record.
this.props.record.load();
this.props.record.model.notify();
} else {
super.updateQuantity(Math.max(quantity, this.productCatalogData.deliveredQty));
}
},
get orderLineComponent() {
if (this.env.orderResModel === "sale.order") {
return ProductCatalogSaleOrderLine;
}
return super.orderLineComponent;
},
});
@@ -1,24 +1,22 @@
/** @odoo-module */
import { ProductCatalogSOL } from "@sale/js/product_catalog/sale_order_line/sale_order_line";
import { patch } from "@web/core/utils/patch";
import { _t } from "@web/core/l10n/translation";
import { ProductCatalogOrderLine } from "@product/product_catalog/order_line/order_line";
patch(ProductCatalogSOL, {
props: {
...ProductCatalogSOL.props,
export class ProductCatalogSaleOrderLine extends ProductCatalogOrderLine {
static props = {
...ProductCatalogOrderLine.props,
deliveredQty: Number,
},
});
}
patch(ProductCatalogSOL.prototype, {
get disableRemove() {
return this.props.quantity === this.props.deliveredQty;
},
}
get disabledButtonTooltip() {
if (this.disableRemove) {
return _t("The ordered quantity cannot be decreased below the amount already delivered. Instead, create a return in your inventory.");
}
return super.disabledButtonTooltip;
},
});
}
}
@@ -1,24 +0,0 @@
<?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>
<a role="menuitem" type="edit" position="after">
<a role="menuitem" type="object" name="action_product_forecast_report" class="dropdown-item border-top-0">View Availability</a>
</a>
</field>
</record>
</odoo>
+22
View File
@@ -234,6 +234,28 @@
</field>
</record>
<record id="product_view_kanban_catalog" model="ir.ui.view">
<field name="name">product.view.kanban.catalog.inherit.stock</field>
<field name="model">product.product</field>
<field name="inherit_id" ref="product.product_view_kanban_catalog"/>
<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="record.detailed_type.raw_value == 'product'"
name="o_kanban_qty_available">
<span>On Hand: </span>
<field name="qty_available"/>
<field name="uom_id" class="ms-1" groups="uom.group_uom"/>
</div>
</div>
<a role="menuitem" type="edit" position="after">
<a role="menuitem" type="object" name="action_product_forecast_report" class="dropdown-item border-top-0">View Availability</a>
</a>
</field>
</record>
<!-- view used for product.product only -->
<record model="ir.ui.view" id="product_form_view_procurement_button">
<field name="name">product.product.procurement</field>