[ADD] sale/purchase : Product Matrix Configurator
This feature branch adds a new way to add products to SOs/POs: the grid (or matrix) entry. When selecting a product template compatible with this mode, a dialog will open with a matrix of available variants and a number input for each of those. Once all quantities are input, closing the dialog will generate the lines for all the selected variants. When more than 2 attributes are present, the matrix is organized by using the first attribute values as X-coordinates and will then combine all other attribute combinations as Y-coordinates. This allows to have potentially huge matrices, although this could be difficult to visually parse by users. If the feature is enabled (the feature is installed independently in sale and/or purchase), this will be the default entry mode for products in purchase orders when multiple variants are available - it's an all or nothing situation. In sales orders, the product template can be configured to switch from the product configurator mode (to select a single variant when adding the product) to the grid mode (to select multiple variants at once). The grid can be re-opened from an existing line (or by creating a new line with the same product) and will repopulate with the current quantities of the order, allowing quick massive edition of the complete set of lines for that product. Task 1871560 closes odoo/odoo#34746 Signed-off-by: Damien Bouvy (dbo) <dbo@odoo.com>
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import itertools
|
||||
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
from odoo import api, fields, models, tools, _
|
||||
from odoo.exceptions import ValidationError, RedirectWarning, UserError
|
||||
@@ -150,12 +151,22 @@ class ProductTemplate(models.Model):
|
||||
item_ids = fields.One2many('product.pricelist.item', 'product_tmpl_id', 'Pricelist Items')
|
||||
|
||||
can_image_1024_be_zoomed = fields.Boolean("Can Image 1024 be zoomed", compute='_compute_can_image_1024_be_zoomed', store=True)
|
||||
has_configurable_attributes = fields.Boolean("Is a configurable product", compute='_compute_has_configurable_attributes', store=True)
|
||||
|
||||
@api.depends('image_1920', 'image_1024')
|
||||
def _compute_can_image_1024_be_zoomed(self):
|
||||
for template in self:
|
||||
template.can_image_1024_be_zoomed = template.image_1920 and tools.is_image_size_above(template.image_1920, template.image_1024)
|
||||
|
||||
@api.depends('attribute_line_ids', 'attribute_line_ids.value_ids', 'attribute_line_ids.attribute_id.create_variant')
|
||||
def _compute_has_configurable_attributes(self):
|
||||
"""A product is considered configurable if:
|
||||
- It has dynamic attributes
|
||||
- It has any attribute line with at least 2 attribute values configured
|
||||
"""
|
||||
for product in self:
|
||||
product.has_configurable_attributes = product.has_dynamic_attributes() or any(len(ptal.value_ids) >= 2 for ptal in product.attribute_line_ids)
|
||||
|
||||
@api.depends('product_variant_ids')
|
||||
def _compute_product_variant_id(self):
|
||||
for p in self:
|
||||
@@ -821,6 +832,51 @@ class ProductTemplate(models.Model):
|
||||
attribute_values = filtered_combination.mapped('product_attribute_value_id')
|
||||
return self.env['product.product'].browse(self._get_variant_id_for_combination(attribute_values))
|
||||
|
||||
def _create_product_variant(self, combination, log_warning=False):
|
||||
""" Create if necessary and possible and return the product variant
|
||||
matching the given combination for this template.
|
||||
|
||||
It is possible to create only if the template has dynamic attributes
|
||||
and the combination itself is possible.
|
||||
|
||||
:param combination: the combination for which to get or create variant.
|
||||
The combination must contain all necessary attributes, including
|
||||
those of type no_variant. Indeed even though those attributes won't
|
||||
be included in the variant if newly created, they are needed when
|
||||
checking if the combination is possible.
|
||||
:type combination: recordset of `product.template.attribute.value`
|
||||
|
||||
:param log_warning: whether a warning should be logged on fail
|
||||
:type log_warning: bool
|
||||
|
||||
:return: the product variant matching the combination or none
|
||||
:rtype: recordset of `product.product`
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
Product = self.env['product.product']
|
||||
|
||||
product_variant = self._get_variant_for_combination(combination)
|
||||
if product_variant:
|
||||
return product_variant
|
||||
|
||||
if not self.has_dynamic_attributes():
|
||||
if log_warning:
|
||||
_logger.warning('The user #%s tried to create a variant for the non-dynamic product %s.' % (self.env.user.id, self.id))
|
||||
return Product
|
||||
|
||||
if not self._is_combination_possible(combination):
|
||||
if log_warning:
|
||||
_logger.warning('The user #%s tried to create an invalid variant for the product %s.' % (self.env.user.id, self.id))
|
||||
return Product
|
||||
|
||||
attribute_values = combination.mapped('product_attribute_value_id')._without_no_variant_attributes()
|
||||
|
||||
return Product.sudo().create({
|
||||
'product_tmpl_id': self.id,
|
||||
'attribute_value_ids': [(6, 0, attribute_values.ids)]
|
||||
})
|
||||
|
||||
@tools.ormcache('self.id', 'attribute_values')
|
||||
def _get_variant_id_for_combination(self, attribute_values):
|
||||
"""See `_get_variant_for_combination`. This method returns an ID
|
||||
@@ -984,6 +1040,20 @@ class ProductTemplate(models.Model):
|
||||
self.ensure_one()
|
||||
return self.env.company
|
||||
|
||||
def get_single_product_variant(self):
|
||||
""" Method used by the product configurator to check if the product is configurable or not.
|
||||
|
||||
We need to open the product configurator if the product:
|
||||
- is configurable (see has_configurable_attributes)
|
||||
- has optional products (method is extended in sale to return optional products info)
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.product_variant_count == 1 and not self.has_configurable_attributes:
|
||||
return {
|
||||
'product_id': self.product_variant_id.id,
|
||||
}
|
||||
return {}
|
||||
|
||||
@api.model
|
||||
def get_empty_list_help(self, help):
|
||||
self = self.with_context(
|
||||
|
||||
@@ -11,6 +11,7 @@ class ResConfigSettings(models.TransientModel):
|
||||
group_uom = fields.Boolean("Units of Measure", implied_group='uom.group_uom')
|
||||
group_product_variant = fields.Boolean("Variants", implied_group='product.group_product_variant')
|
||||
module_sale_product_configurator = fields.Boolean("Product Configurator")
|
||||
module_sale_product_matrix = fields.Boolean("Product Grid Configurator")
|
||||
group_stock_packaging = fields.Boolean('Product Packagings',
|
||||
implied_group='product.group_stock_packaging')
|
||||
group_sale_pricelist = fields.Boolean("Use pricelists to adapt your price per customers",
|
||||
@@ -36,6 +37,8 @@ class ResConfigSettings(models.TransientModel):
|
||||
If the user disables the product variants -> disable the product configurator as well"""
|
||||
if self.module_sale_product_configurator and not self.group_product_variant:
|
||||
self.module_sale_product_configurator = False
|
||||
if self.module_sale_product_matrix and not self.group_product_variant:
|
||||
self.module_sale_product_matrix = False
|
||||
|
||||
@api.onchange('module_sale_product_configurator')
|
||||
def _onchange_module_sale_product_configurator(self):
|
||||
@@ -49,3 +52,10 @@ class ResConfigSettings(models.TransientModel):
|
||||
if not self.group_discount_per_so_line:
|
||||
pl = self.env['product.pricelist'].search([('discount_policy', '=', 'without_discount')])
|
||||
pl.write({'discount_policy': 'with_discount'})
|
||||
|
||||
@api.onchange('module_sale_product_matrix')
|
||||
def _onchange_module_module_sale_product_matrix(self):
|
||||
"""The product Grid Configurator requires the product Configurator activated
|
||||
If the user enables the Grid Configurator -> enable the product Configurator as well"""
|
||||
if self.module_sale_product_matrix and not self.module_sale_product_configurator:
|
||||
self.module_sale_product_configurator = True
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
{
|
||||
'name': "Product Matrix",
|
||||
'summary': """
|
||||
Technical module: Matrix Implementation
|
||||
""",
|
||||
'description': """
|
||||
Please refer to Sale Matrix or Purchase Matrix for the uses of this module.
|
||||
""",
|
||||
'category': 'Sales',
|
||||
'version': '1.0',
|
||||
'depends': ['account'],
|
||||
# Account dependency for section_and_note widget.
|
||||
'qweb': [
|
||||
"static/src/xml/product_matrix.xml",
|
||||
],
|
||||
'data': [
|
||||
'views/assets.xml',
|
||||
'views/matrix_templates.xml',
|
||||
],
|
||||
'demo': [
|
||||
'data/product_matrix_demo.xml',
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="product_attribute_size" model="product.attribute">
|
||||
<field name="name">Size</field>
|
||||
</record>
|
||||
<record id="product_attribute_value_size_xs" model="product.attribute.value">
|
||||
<field name="name">XS</field>
|
||||
<field name="attribute_id" ref="product_attribute_size"/>
|
||||
<field name="sequence">1</field>
|
||||
</record>
|
||||
<record id="product_attribute_value_size_s" model="product.attribute.value">
|
||||
<field name="name">S</field>
|
||||
<field name="attribute_id" ref="product_attribute_size"/>
|
||||
<field name="sequence">2</field>
|
||||
</record>
|
||||
<record id="product_attribute_value_size_m" model="product.attribute.value">
|
||||
<field name="name">M</field>
|
||||
<field name="attribute_id" ref="product_attribute_size"/>
|
||||
<field name="sequence">3</field>
|
||||
</record>
|
||||
<record id="product_attribute_value_size_l" model="product.attribute.value">
|
||||
<field name="name">L</field>
|
||||
<field name="attribute_id" ref="product_attribute_size"/>
|
||||
<field name="sequence">4</field>
|
||||
</record>
|
||||
<record id="product_attribute_value_size_xl" model="product.attribute.value">
|
||||
<field name="name">XL</field>
|
||||
<field name="attribute_id" ref="product_attribute_size"/>
|
||||
<field name="sequence">5</field>
|
||||
</record>
|
||||
<record id="product_attribute_value_size_xxl" model="product.attribute.value">
|
||||
<field name="name">2XL</field>
|
||||
<field name="attribute_id" ref="product_attribute_size"/>
|
||||
<field name="sequence">6</field>
|
||||
</record>
|
||||
<record id="product_attribute_value_size_xxxl" model="product.attribute.value">
|
||||
<field name="name">3XL</field>
|
||||
<field name="attribute_id" ref="product_attribute_size"/>
|
||||
<field name="sequence">7</field>
|
||||
</record>
|
||||
|
||||
<record id="product_attribute_gender" model="product.attribute">
|
||||
<field name="name">Gender</field>
|
||||
</record>
|
||||
<record id="product_attribute_value_m" model="product.attribute.value">
|
||||
<field name="name">Men</field>
|
||||
<field name="attribute_id" ref="product_attribute_gender"/>
|
||||
<field name="sequence">1</field>
|
||||
</record>
|
||||
<record id="product_attribute_value_w" model="product.attribute.value">
|
||||
<field name="name">Women</field>
|
||||
<field name="attribute_id" ref="product_attribute_gender"/>
|
||||
<field name="sequence">2</field>
|
||||
</record>
|
||||
|
||||
<record id="product_attribute_sleeves" model="product.attribute">
|
||||
<field name="name">Sleeves</field>
|
||||
</record>
|
||||
<record id="product_attribute_value_ss" model="product.attribute.value">
|
||||
<field name="name">Short Sleeves</field>
|
||||
<field name="attribute_id" ref="product_attribute_sleeves"/>
|
||||
<field name="sequence">1</field>
|
||||
</record>
|
||||
<record id="product_attribute_value_ls" model="product.attribute.value">
|
||||
<field name="name">Long Sleeves</field>
|
||||
<field name="attribute_id" ref="product_attribute_sleeves"/>
|
||||
<field name="sequence">2</field>
|
||||
</record>
|
||||
|
||||
<!-- Color Attribute Values -->
|
||||
<record id="product_attribute_value_color_1" model="product.attribute.value">
|
||||
<field name="name">Blue</field>
|
||||
<field name="attribute_id" ref="product.product_attribute_2"/>
|
||||
<field name="sequence">3</field>
|
||||
</record>
|
||||
<record id="product_attribute_value_color_2" model="product.attribute.value">
|
||||
<field name="name">Pink</field>
|
||||
<field name="attribute_id" ref="product.product_attribute_2"/>
|
||||
<field name="sequence">4</field>
|
||||
</record>
|
||||
<record id="product_attribute_value_color_3" model="product.attribute.value">
|
||||
<field name="name">Yellow</field>
|
||||
<field name="attribute_id" ref="product.product_attribute_2"/>
|
||||
<field name="sequence">5</field>
|
||||
</record>
|
||||
<record id="product_attribute_value_color_4" model="product.attribute.value">
|
||||
<field name="name">Rainbow</field>
|
||||
<field name="attribute_id" ref="product.product_attribute_2"/>
|
||||
<field name="sequence">6</field>
|
||||
</record>
|
||||
|
||||
<record id="matrix_product_template_shirt" model="product.template">
|
||||
<field name="name">My Company Tshirt</field>
|
||||
<field name="categ_id" ref="product.product_category_6"/>
|
||||
<field name="default_code">GRID</field>
|
||||
<field name="standard_price">7.0</field>
|
||||
<field name="list_price">15.0</field>
|
||||
<field name="type">consu</field>
|
||||
<field name="uom_id" ref="uom.product_uom_unit"/>
|
||||
<field name="uom_po_id" ref="uom.product_uom_unit"/>
|
||||
<field name="description_sale">Show your company love around you =).</field>
|
||||
<field name="image_1920" type="base64" file="product_matrix/static/img/matrix_mycompany_tshirt.jpeg"/>
|
||||
</record>
|
||||
|
||||
<record id="product_template_attribute_line_size" model="product.template.attribute.line">
|
||||
<field name="product_tmpl_id" ref="matrix_product_template_shirt"/>
|
||||
<field name="attribute_id" ref="product_attribute_size"/>
|
||||
<field name="value_ids" eval="[
|
||||
(6, 0, [
|
||||
ref('product_attribute_value_size_xs'),
|
||||
ref('product_attribute_value_size_s'),
|
||||
ref('product_attribute_value_size_m'),
|
||||
ref('product_attribute_value_size_l'),
|
||||
ref('product_attribute_value_size_xl'),
|
||||
ref('product_attribute_value_size_xxl'),
|
||||
ref('product_attribute_value_size_xxxl')])]"/>
|
||||
</record>
|
||||
<record id="product_template_attribute_line_gender" model="product.template.attribute.line">
|
||||
<field name="product_tmpl_id" ref="matrix_product_template_shirt"/>
|
||||
<field name="attribute_id" ref="product_attribute_gender"/>
|
||||
<field name="value_ids" eval="[(6, 0, [ref('product_attribute_value_m'), ref('product_attribute_value_w')])]"/>
|
||||
</record>
|
||||
<record id="product_template_attribute_line_sleeves" model="product.template.attribute.line">
|
||||
<field name="product_tmpl_id" ref="matrix_product_template_shirt"/>
|
||||
<field name="attribute_id" ref="product_attribute_sleeves"/>
|
||||
<field name="value_ids" eval="[(6, 0, [ref('product_attribute_value_ss'), ref('product_attribute_value_ls')])]"/>
|
||||
</record>
|
||||
<record id="product_template_attribute_line_color" model="product.template.attribute.line">
|
||||
<field name="product_tmpl_id" ref="matrix_product_template_shirt"/>
|
||||
<field name="attribute_id" ref="product.product_attribute_2"/>
|
||||
<field name="value_ids" eval="[(6, 0, [ref('product_attribute_value_color_1'), ref('product_attribute_value_color_2'), ref('product_attribute_value_color_3'), ref('product_attribute_value_color_4')])]"/>
|
||||
</record>
|
||||
|
||||
<function model="product.template" name="create_variant_ids" eval="([ref('product_matrix.matrix_product_template_shirt')])"/>
|
||||
</odoo>
|
||||
@@ -0,0 +1 @@
|
||||
from . import product_template
|
||||
@@ -0,0 +1,92 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import itertools
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class ProductTemplate(models.Model):
|
||||
_inherit = 'product.template'
|
||||
|
||||
def _get_template_matrix(self, **kwargs):
|
||||
self.ensure_one()
|
||||
company_id = kwargs.get('company_id', None) or self.company_id or self.env.company
|
||||
currency_id = kwargs.get('currency_id', None) or self.currency_id
|
||||
display_extra = kwargs.get('display_extra_price', False)
|
||||
attribute_lines = self.valid_product_template_attribute_line_ids
|
||||
|
||||
Attrib = self.env['product.template.attribute.value']
|
||||
first_line_attributes = attribute_lines[0].product_template_value_ids
|
||||
attribute_ids_by_line = [line.product_template_value_ids.ids for line in attribute_lines]
|
||||
|
||||
header = [{"name": self.display_name}] + [
|
||||
attr._grid_header_cell(
|
||||
fro_currency=self.currency_id,
|
||||
to_currency=currency_id,
|
||||
company=company_id,
|
||||
display_extra=display_extra
|
||||
) for attr in first_line_attributes]
|
||||
|
||||
result = [[]]
|
||||
for pool in attribute_ids_by_line:
|
||||
result = [x + [y] for y in pool for x in result]
|
||||
args = [iter(result)] * len(first_line_attributes)
|
||||
rows = itertools.zip_longest(*args)
|
||||
|
||||
matrix = []
|
||||
for row in rows:
|
||||
row_attributes = Attrib.browse(row[0][1:])
|
||||
row_header_cell = row_attributes._grid_header_cell(
|
||||
fro_currency=self.currency_id,
|
||||
to_currency=currency_id,
|
||||
company=company_id,
|
||||
display_extra=display_extra)
|
||||
result = [row_header_cell]
|
||||
|
||||
for cell in row:
|
||||
combination = Attrib.browse(cell)
|
||||
is_possible_combination = self._is_combination_possible(combination)
|
||||
cell.sort()
|
||||
result.append({
|
||||
"ptav_ids": cell,
|
||||
"qty": 0,
|
||||
"is_possible_combination": is_possible_combination
|
||||
})
|
||||
matrix.append(result)
|
||||
|
||||
return {
|
||||
"header": header,
|
||||
"matrix": matrix,
|
||||
}
|
||||
|
||||
|
||||
class ProductTemplateAttributeValue(models.Model):
|
||||
_inherit = "product.template.attribute.value"
|
||||
|
||||
def _grid_header_cell(self, fro_currency, to_currency, company, display_extra=True):
|
||||
"""Generate a header matrix cell for 1 or multiple attributes.
|
||||
|
||||
:param res.currency fro_currency:
|
||||
:param res.currency to_currency:
|
||||
:param res.company company:
|
||||
:param bool display_extra: whether extra prices should be displayed in the cell
|
||||
True by default, used to avoid showing extra prices on purchases.
|
||||
:returns: cell with name (and price if any price_extra is defined on self)
|
||||
:rtype: dict
|
||||
"""
|
||||
header_cell = {
|
||||
'name': '-'.join([attr.name for attr in self])
|
||||
}
|
||||
extra_price = sum(self.mapped('price_extra')) if display_extra else 0
|
||||
if extra_price:
|
||||
sign = '+ ' if self.price_extra > 0 else '- '
|
||||
header_cell.update({
|
||||
"price": sign + self.env['ir.qweb.field.monetary'].value_to_html(
|
||||
extra_price, {
|
||||
'from_currency': fro_currency,
|
||||
'display_currency': to_currency,
|
||||
'company_id': company,
|
||||
}
|
||||
)
|
||||
})
|
||||
return header_cell
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
@@ -0,0 +1,139 @@
|
||||
odoo.define('product_matrix.section_and_note_widget', function (require) {
|
||||
|
||||
var Dialog = require('web.Dialog');
|
||||
var core = require('web.core');
|
||||
var _t = core._t;
|
||||
var qweb = core.qweb;
|
||||
var fieldRegistry = require('web.field_registry');
|
||||
require('account.section_and_note_backend');
|
||||
|
||||
var SectionAndNoteFieldOne2Many = fieldRegistry.get('section_and_note_one2many');
|
||||
|
||||
SectionAndNoteFieldOne2Many.include({
|
||||
custom_events: _.extend({}, SectionAndNoteFieldOne2Many.prototype.custom_events, {
|
||||
open_matrix: '_openMatrix',
|
||||
}),
|
||||
|
||||
/**
|
||||
* Sends the matrix modifications to the server
|
||||
* Through a change on an invisible non stored field.
|
||||
*
|
||||
* @param {List} list of changes in the matrix, to be applied to the order.
|
||||
* {integer} quantity: float
|
||||
* {List} ptav_ids: product.template.attribute.value ids
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_applyGrid: function (changes, productTemplateId) {
|
||||
// the getParent is to trigger the event on the form Controller instead of the one2many.
|
||||
// If not, the one2many crashes on reset because it tries to find an operation in the event
|
||||
// even if there isn't any.
|
||||
// the only solution would be to use a custom event catched on a new controller
|
||||
// on the so/po form (as a js_class).
|
||||
this.getParent().trigger_up('field_changed', {
|
||||
dataPointID: this.dataPointID,
|
||||
changes: {
|
||||
grid: JSON.stringify({changes: changes, product_template_id: productTemplateId}),
|
||||
grid_update: true // to say that the changes to grid have to be applied to the SO.
|
||||
},
|
||||
viewType: 'form',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Catches the event asking for matrix opening
|
||||
*
|
||||
* @param {OdooEvent} ev various values needed to open the matrix
|
||||
* {integer} data.product_template_id product.template id
|
||||
* {list} data.editedCellAttributes list of product.template.attribute.value ids
|
||||
* {bool} data.edit whether the line source should be deleted or not.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_openMatrix: function (ev) {
|
||||
ev.stopPropagation();
|
||||
var self = this;
|
||||
var dataPointId = ev.data.dataPointId;
|
||||
var productTemplateId = ev.data.product_template_id;
|
||||
var editedCellAttributes = ev.data.editedCellAttributes;
|
||||
if (!ev.data.edit) {
|
||||
// remove the line used to open the matrix
|
||||
this._setValue({operation: 'DELETE', ids: [dataPointId]});
|
||||
}
|
||||
// the getParent is to trigger the event on the form Controller instead of the one2many.
|
||||
// If not, the one2many crashes on reset because it tries to find an operation in the event
|
||||
// even if there isn't any.
|
||||
// the only solution would be to use a custom event catched on a new controller
|
||||
// on the so/po form (as a js_class).
|
||||
this.getParent().trigger_up('field_changed', {
|
||||
dataPointID: this.dataPointID,
|
||||
changes: {
|
||||
grid_product_tmpl_id: {id: productTemplateId}
|
||||
},
|
||||
viewType: 'form',
|
||||
onSuccess: function (result) {
|
||||
// why the hell do we receive a table of 8 classes here? :o
|
||||
self._openMatrixConfigurator(result[0].recordData.grid, productTemplateId, editedCellAttributes);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Triggers Matrix Dialog opening
|
||||
*
|
||||
* @param {String} jsonInfo matrix dialog content
|
||||
* @param {integer} productTemplateId product.template id
|
||||
* @param {editedCellAttributes} list of product.template.attribute.value ids
|
||||
* used to focus on the matrix cell representing the edited line.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_openMatrixConfigurator: function (jsonInfo, productTemplateId, editedCellAttributes) {
|
||||
var self = this;
|
||||
var infos = JSON.parse(jsonInfo);
|
||||
var MatrixDialog = new Dialog(this, {
|
||||
title: _t('Choose Product Variants'),
|
||||
size: 'extra-large', // adapt size depending on matrix size?
|
||||
$content: $(qweb.render(
|
||||
'product_matrix.matrix', {
|
||||
header: infos.header,
|
||||
rows: infos.matrix,
|
||||
}
|
||||
)),
|
||||
buttons: [
|
||||
{text: _t('Confirm'), classes: 'btn-primary', close: true, click: function (result) {
|
||||
var $inputs = this.$('.o_grid_input');
|
||||
var matrixChanges = [];
|
||||
_.each($inputs, function (matrixInput) {
|
||||
if (matrixInput.value && matrixInput.value !== matrixInput.attributes.value.nodeValue) {
|
||||
matrixChanges.push({
|
||||
qty: parseFloat(matrixInput.value),
|
||||
ptav_ids: matrixInput.attributes.ptav_ids.nodeValue.split(",").map(function (id) {
|
||||
return parseInt(id);
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
if (matrixChanges.length > 0) {
|
||||
self._applyGrid(matrixChanges, productTemplateId);
|
||||
}
|
||||
}},
|
||||
{text: _t('Close'), close: true},
|
||||
],
|
||||
}).open();
|
||||
|
||||
MatrixDialog.opened(function () {
|
||||
if (editedCellAttributes.length > 0) {
|
||||
var str = editedCellAttributes.toString();
|
||||
MatrixDialog.$content.find('.o_grid_input').filter((k, v) => v.attributes.ptav_ids.nodeValue === str)[0].focus();
|
||||
} else {
|
||||
MatrixDialog.$content.find('.o_grid_input:first()').focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
return SectionAndNoteFieldOne2Many;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
.o_web_client .o_view_grid {
|
||||
cursor: default;
|
||||
|
||||
table {
|
||||
margin-bottom: 0;
|
||||
table-layout: fixed;
|
||||
min-width: 100%;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
th, td {
|
||||
border: 0 !important;
|
||||
vertical-align: middle;
|
||||
width: 5em;
|
||||
}
|
||||
.o_grid_title_header {
|
||||
width: 10em;
|
||||
}
|
||||
thead {
|
||||
color: $o-main-text-color;
|
||||
background-color: $o-brand-lightsecondary;
|
||||
th {
|
||||
text-align: center;
|
||||
white-space: pre-line;
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
background-color: $o-view-background-color;
|
||||
text-align: right;
|
||||
th > div {
|
||||
display: flex;
|
||||
> div {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
&:before {
|
||||
content: "/\00a0";
|
||||
padding: 0 2px;
|
||||
}
|
||||
&:first-child:before {
|
||||
content: " ";
|
||||
}
|
||||
&:last-child {
|
||||
flex: 100 1 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
tr:nth-of-type(odd) th > div:after {
|
||||
background-color: $table-accent-bg;
|
||||
}
|
||||
.o_grid_cell_container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.075), 0 0 5px $o-brand-primary;
|
||||
|
||||
}
|
||||
}
|
||||
.o_grid_input {
|
||||
cursor: text;
|
||||
text-align: right;
|
||||
&:focus {
|
||||
opacity: 1 !important;
|
||||
background-color: $o-view-background-color;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.o_grid_text_muted{
|
||||
color: lighten($o-main-text-color, 15%);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// ensure white background completely surrounds nocontent bubble
|
||||
.o_grid_nocontent_container {
|
||||
overflow: auto;
|
||||
|
||||
.oe_view_nocontent_img_link {
|
||||
padding:10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_product_variant_grid {
|
||||
.form-control {
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
border: 1px solid $gray-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div t-name="product_matrix.matrix">
|
||||
<table class="o_view_grid o_product_variant_grid table table-sm table-striped table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th t-foreach="header" t-as="column_header" class="o_grid_title_header text-center">
|
||||
<span t-esc="column_header.name"/>
|
||||
<t t-call="product_matrix.extra_price">
|
||||
<t t-set="price" t-value="column_header.price"/>
|
||||
</t>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="rows" t-as="row">
|
||||
<t t-foreach="row" t-as="cell">
|
||||
<td t-if="cell.name" class="text-center">
|
||||
<strong t-esc="cell.name"/>
|
||||
<t t-call="product_matrix.extra_price">
|
||||
<t t-set="price" t-value="cell.price"/>
|
||||
</t>
|
||||
</td>
|
||||
<td t-else="">
|
||||
<div t-if="cell.is_possible_combination" class="input-group o_grid_cell_container">
|
||||
<input type="number"
|
||||
class="o_grid_cell form-control o_grid_input"
|
||||
t-att-ptav_ids="cell.ptav_ids"
|
||||
t-att-value="cell.qty"/>
|
||||
</div>
|
||||
<span t-else="" class="o_grid_cell o_grid_text_muted o_grid_nocontent_container"> Not available </span>
|
||||
</td>
|
||||
</t>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<span t-name="product_matrix.extra_price" t-if="price" class="badge badge-pill badge-secondary">
|
||||
<!--
|
||||
price_extra is displayed as catalog price instead of
|
||||
price after pricelist because it is impossible to
|
||||
compute. Indeed, the pricelist rule might depend on the
|
||||
selected variant, so the price_extra will be different
|
||||
depending on the selected combination. The price of an
|
||||
attribute is therefore variable and it's not very
|
||||
accurate to display it.
|
||||
-->
|
||||
<span class="variant_price_extra" style="white-space: nowrap;">
|
||||
<t t-raw="price"/>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="assets_backend_inherit_sale" inherit_id="web.assets_backend" name="Sale Grid assets">
|
||||
<xpath expr="script[last()]" position="after">
|
||||
<script type="text/javascript" src="/product_matrix/static/src/js/section_and_note_widget.js"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/product_matrix/static/src/scss/product_matrix.scss"/>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="matrix">
|
||||
<t t-foreach="order.get_report_matrixes()" t-as="grid">
|
||||
<table class="o_view_grid o_product_variant_grid table table-sm table-striped table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th t-foreach="grid['header']" t-as="column_header" class="o_grid_title_header text-center">
|
||||
<span t-esc="column_header['name']"/>
|
||||
<t t-call="product_matrix.extra_price">
|
||||
<t t-set="price" t-value="column_header.get('price', False)"/>
|
||||
</t>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="grid['matrix']" t-as="row">
|
||||
<t t-foreach="row" t-as="cell">
|
||||
<td t-if="cell.get('name', False)" class="text-left">
|
||||
<strong t-esc="cell['name']"/>
|
||||
<t t-call="product_matrix.extra_price">
|
||||
<t t-set="price" t-value="cell.get('price', False)"/>
|
||||
</t>
|
||||
</td>
|
||||
<td t-else="" class="text-right">
|
||||
<span t-esc="cell.get('qty', 0)" class="o_grid_cell_container"/>
|
||||
</td>
|
||||
</t>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="extra_price">
|
||||
<span t-if="price" class="badge badge-pill badge-secondary">
|
||||
<!--
|
||||
price_extra is displayed as catalog price instead of
|
||||
price after pricelist because it is impossible to
|
||||
compute. Indeed, the pricelist rule might depend on the
|
||||
selected variant, so the price_extra will be different
|
||||
depending on the selected combination. The price of an
|
||||
attribute is therefore variable and it's not very
|
||||
accurate to display it.
|
||||
-->
|
||||
<span class="variant_price_extra" style="white-space: nowrap;">
|
||||
<t t-raw="price"/>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -598,8 +598,17 @@ class PurchaseOrderLine(models.Model):
|
||||
# Reset date, price and quantity since _onchange_quantity will provide default values
|
||||
self.date_planned = datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT)
|
||||
self.price_unit = self.product_qty = 0.0
|
||||
self.product_uom = self.product_id.uom_po_id or self.product_id.uom_id
|
||||
|
||||
self._product_id_change()
|
||||
|
||||
self._suggest_quantity()
|
||||
self._onchange_quantity()
|
||||
|
||||
def _product_id_change(self):
|
||||
if not self.product_id:
|
||||
return
|
||||
|
||||
self.product_uom = self.product_id.uom_po_id or self.product_id.uom_id
|
||||
product_lang = self.product_id.with_context(
|
||||
lang=self.partner_id.lang,
|
||||
partner_id=self.partner_id.id,
|
||||
@@ -611,9 +620,6 @@ class PurchaseOrderLine(models.Model):
|
||||
|
||||
self._compute_tax_id()
|
||||
|
||||
self._suggest_quantity()
|
||||
self._onchange_quantity()
|
||||
|
||||
@api.onchange('product_id')
|
||||
def onchange_product_id_warning(self):
|
||||
if not self.product_id:
|
||||
|
||||
@@ -25,6 +25,7 @@ class ResConfigSettings(models.TransientModel):
|
||||
implied_group="purchase.group_manage_vendor_price")
|
||||
module_account_3way_match = fields.Boolean("3-way matching: purchases, receptions and bills")
|
||||
module_purchase_requisition = fields.Boolean("Purchase Agreements")
|
||||
module_purchase_product_matrix = fields.Boolean("Variants Grid Entry")
|
||||
po_lead = fields.Float(related='company_id.po_lead', readonly=False)
|
||||
use_po_lead = fields.Boolean(
|
||||
string="Security Lead Time for Purchase",
|
||||
|
||||
@@ -338,13 +338,13 @@
|
||||
</page>
|
||||
<page string="Other Information" name="purchase_delivery_invoice">
|
||||
<group>
|
||||
<group>
|
||||
<group name="planning">
|
||||
<label for="date_planned"/>
|
||||
<div>
|
||||
<field name="date_planned" attrs="{'readonly': [('state', 'not in', ('draft', 'sent'))]}"/>
|
||||
</div>
|
||||
</group>
|
||||
<group>
|
||||
<group name="other_info">
|
||||
<field name="user_id"/>
|
||||
<field name="invoice_status"/>
|
||||
<field name="payment_term_id" attrs="{'readonly': ['|', ('invoice_status','=', 'invoiced'), ('state', '=', 'done')]}"/>
|
||||
|
||||
@@ -118,6 +118,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box" title="If installed, the product variants will be added to purchase orders through a grid entry.">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="module_purchase_product_matrix"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="module_purchase_product_matrix"/>
|
||||
<div class="text-muted">
|
||||
Add several variants to the purchase order using a grid.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from . import models
|
||||
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
{
|
||||
'name': "Purchase Matrix",
|
||||
'summary': """
|
||||
Add variants to your purchase orders through an Order Grid Entry.
|
||||
""",
|
||||
'description': """
|
||||
This module allows to fill Purchase Orders rapidly
|
||||
by choosing product variants quantity through a Grid Entry.
|
||||
""",
|
||||
'category': 'Sales',
|
||||
'version': '1.0',
|
||||
'depends': ['purchase', 'product_matrix'],
|
||||
'data': [
|
||||
'views/assets.xml',
|
||||
'views/purchase_views.xml',
|
||||
'report/purchase_quotation_templates.xml',
|
||||
'report/purchase_order_templates.xml',
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from . import purchase
|
||||
@@ -0,0 +1,148 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import json
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class PurchaseOrder(models.Model):
|
||||
_inherit = 'purchase.order'
|
||||
|
||||
report_grids = fields.Boolean(string="Show grids on reports", default=True, help="If set, the matrix of configurable products will be shown on the report of this order.")
|
||||
|
||||
""" Matrix loading and update: fields and methods :
|
||||
|
||||
NOTE: The matrix functionality was done in python, server side, to avoid js
|
||||
restriction. Indeed, the js framework only loads the x first lines displayed
|
||||
in the client, which means in case of big matrices and lots of so_lines,
|
||||
the js doesn't have access to the 41st and following lines.
|
||||
|
||||
To force the loading, a 'hack' of the js framework would have been needed...
|
||||
"""
|
||||
|
||||
grid_product_tmpl_id = fields.Many2one('product.template', store=False, help="Technical field for product_matrix functionalities.")
|
||||
grid_update = fields.Boolean(default=False, store=False, help="Whether the grid field contains a new matrix to apply or not.")
|
||||
grid = fields.Char(store=False, help="Technical storage of grid. \nIf grid_update, will be loaded on the SO. \nIf not, represents the matrix to open.")
|
||||
|
||||
@api.onchange('grid_product_tmpl_id')
|
||||
def _set_grid_up(self):
|
||||
if self.grid_product_tmpl_id:
|
||||
self.grid_update = False
|
||||
self.grid = json.dumps(self._get_matrix(self.grid_product_tmpl_id))
|
||||
|
||||
@api.onchange('grid')
|
||||
def _apply_grid(self):
|
||||
if self.grid and self.grid_update:
|
||||
grid = json.loads(self.grid)
|
||||
product_template = self.env['product.template'].browse(grid['product_template_id'])
|
||||
dirty_cells = grid['changes']
|
||||
Attrib = self.env['product.template.attribute.value']
|
||||
default_po_line_vals = {}
|
||||
new_lines = []
|
||||
for cell in dirty_cells:
|
||||
combination = Attrib.browse(cell['ptav_ids'])
|
||||
no_variant_attribute_values = combination - combination._without_no_variant_attributes()
|
||||
|
||||
# create or find product variant from combination
|
||||
product = product_template._create_product_variant(combination)
|
||||
# TODO replace the check on product_id by a first check on the ptavs and pnavs?
|
||||
# and only create/require variant after no line has been found ???
|
||||
order_lines = self.order_line.filtered(lambda line: line.product_id == product and line.product_no_variant_attribute_value_ids == no_variant_attribute_values)
|
||||
|
||||
# if product variant already exist in order lines
|
||||
old_qty = sum(order_lines.mapped('product_qty'))
|
||||
qty = cell['qty']
|
||||
diff = qty - old_qty
|
||||
if diff and order_lines:
|
||||
if qty == 0:
|
||||
if self.state in ['draft', 'sent']:
|
||||
# Remove lines if qty was set to 0 in matrix
|
||||
# only if SO state = draft/sent
|
||||
self.order_line -= order_lines
|
||||
else:
|
||||
order_lines.update({'product_qty': 0.0})
|
||||
else:
|
||||
"""
|
||||
When there are multiple lines for same product and its quantity was changed in the matrix,
|
||||
An error is raised.
|
||||
|
||||
A 'good' strategy would be to:
|
||||
* Sets the quantity of the first found line to the cell value
|
||||
* Remove the other lines.
|
||||
|
||||
But this would remove all business logic linked to the other lines...
|
||||
Therefore, it only raises an Error for now.
|
||||
"""
|
||||
if len(order_lines) > 1:
|
||||
raise ValidationError(_("You cannot change the quantity of a product present in multiple purchase lines."))
|
||||
else:
|
||||
order_lines[0].product_qty = qty
|
||||
order_lines[0]._onchange_quantity()
|
||||
# If we want to support multiple lines edition:
|
||||
# removal of other lines.
|
||||
# For now, an error is raised instead
|
||||
# if len(order_lines) > 1:
|
||||
# # Remove 1+ lines
|
||||
# self.order_line -= order_lines[1:]
|
||||
elif diff:
|
||||
if not default_po_line_vals:
|
||||
OrderLine = self.env['purchase.order.line']
|
||||
default_po_line_vals = OrderLine.default_get(OrderLine._fields.keys())
|
||||
pnvav = [] # product no variant attribute values
|
||||
for nvav in no_variant_attribute_values:
|
||||
pnvav.append((4, nvav))
|
||||
new_lines.append((0, 0, dict(
|
||||
default_po_line_vals,
|
||||
product_id=product.id,
|
||||
product_qty=qty,
|
||||
product_no_variant_attribute_value_ids=pnvav)
|
||||
))
|
||||
if new_lines:
|
||||
self.update(dict(order_line=new_lines))
|
||||
for line in self.order_line.filtered(lambda line: line.product_template_id == product_template):
|
||||
line._product_id_change()
|
||||
line._onchange_quantity()
|
||||
|
||||
def _get_matrix(self, product_template):
|
||||
def has_ptavs(line, sorted_attr_ids):
|
||||
ptav = line.product_template_attribute_value_ids.ids
|
||||
pnav = line.product_no_variant_attribute_value_ids.ids
|
||||
pav = pnav + ptav
|
||||
pav.sort()
|
||||
return pav == sorted_attr_ids
|
||||
matrix = product_template._get_template_matrix(
|
||||
company_id=self.company_id,
|
||||
currency_id=self.currency_id)
|
||||
if self.order_line:
|
||||
lines = matrix['matrix']
|
||||
order_lines = self.order_line.filtered(lambda line: line.product_template_id == product_template)
|
||||
for line in lines:
|
||||
for cell in line:
|
||||
if not cell.get('name', False):
|
||||
line = order_lines.filtered(lambda line: has_ptavs(line, cell['ptav_ids']))
|
||||
if line:
|
||||
cell.update({
|
||||
'qty': sum(line.mapped('product_qty'))
|
||||
})
|
||||
return matrix
|
||||
|
||||
def get_report_matrixes(self):
|
||||
"""Reporting method."""
|
||||
matrixes = []
|
||||
if self.report_grids:
|
||||
grid_configured_templates = self.order_line.filtered('is_configurable_product').product_template_id
|
||||
# TODO is configurable product and product_variant_count > 1
|
||||
# configurable products are only configured through the matrix in purchase, so no need to check product_add_mode.
|
||||
for template in grid_configured_templates:
|
||||
if len(self.order_line.filtered(lambda line: line.product_template_id == template)) > 1:
|
||||
matrixes.append(self._get_matrix(template))
|
||||
return matrixes
|
||||
|
||||
|
||||
class PurchaseOrderLine(models.Model):
|
||||
_inherit = "purchase.order.line"
|
||||
|
||||
product_template_id = fields.Many2one('product.template', string='Product Template', related="product_id.product_tmpl_id", domain=[('purchase_ok', '=', True)])
|
||||
is_configurable_product = fields.Boolean('Is the product configurable?', related="product_template_id.has_configurable_attributes")
|
||||
product_template_attribute_value_ids = fields.Many2many(related='product_id.product_template_attribute_value_ids', readonly=True)
|
||||
product_no_variant_attribute_value_ids = fields.Many2many('product.template.attribute.value', string='Product attribute values that do not create variants')
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="grid_purchaseorder_inherit" inherit_id="purchase.report_purchaseorder_document">
|
||||
<xpath expr="//div[@id='informations']" position="after">
|
||||
<t t-call="product_matrix.matrix">
|
||||
<t t-set="order" t-value="o"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="grid_report_purchaseorder_template_inherit" inherit_id="purchase.report_purchasequotation_document">
|
||||
<xpath expr="//table[hasclass('table')]" position="before">
|
||||
<t t-call="product_matrix.matrix">
|
||||
<t t-set="order" t-value="o"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -0,0 +1,187 @@
|
||||
odoo.define('purchase.product_matrix_configurator', function (require) {
|
||||
|
||||
var relationalFields = require('web.relational_fields');
|
||||
var FieldsRegistry = require('web.field_registry');
|
||||
var core = require('web.core');
|
||||
var _t = core._t;
|
||||
|
||||
/**
|
||||
* The purchase.product_matrix_configurator widget is a widget extending FieldMany2One
|
||||
* It triggers the opening of the matrix edition when the product has multiple variants.
|
||||
*
|
||||
*
|
||||
* !!! WARNING !!!
|
||||
*
|
||||
* This widget is only designed for Purchase Order Lines.
|
||||
* !!! It should only be used on a product_template field !!!
|
||||
*/
|
||||
var MatrixConfiguratorWidget = relationalFields.FieldMany2One.extend({
|
||||
events: _.extend({}, relationalFields.FieldMany2One.prototype.events, {
|
||||
'click .o_edit_product_configuration': '_onEditProductConfiguration'
|
||||
}),
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_render: function () {
|
||||
this._super.apply(this, arguments);
|
||||
if (this.mode === 'edit' && this.value &&
|
||||
(this._isConfigurableProduct())) {
|
||||
this._addProductLinkButton();
|
||||
this._addConfigurationEditButton();
|
||||
} else if (this.mode === 'edit' && this.value) {
|
||||
this._addProductLinkButton();
|
||||
} else {
|
||||
this.$('.o_edit_product_configuration').hide();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Add button linking to product_id/product_template_id form.
|
||||
*/
|
||||
_addProductLinkButton: function () {
|
||||
if (this.$('.o_external_button').length === 0) {
|
||||
var $productLinkButton = $('<button>', {
|
||||
type: 'button',
|
||||
class: 'fa fa-external-link btn btn-secondary o_external_button',
|
||||
tabindex: '-1',
|
||||
draggable: false,
|
||||
'aria-label': _t('External Link'),
|
||||
title: _t('External Link')
|
||||
});
|
||||
|
||||
var $inputDropdown = this.$('.o_input_dropdown');
|
||||
$inputDropdown.after($productLinkButton);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* If current product is configurable,
|
||||
* Show edit button (in Edit Mode) after the product/product_template
|
||||
*/
|
||||
_addConfigurationEditButton: function () {
|
||||
var $inputDropdown = this.$('.o_input_dropdown');
|
||||
|
||||
if ($inputDropdown.length !== 0 &&
|
||||
this.$('.o_edit_product_configuration').length === 0) {
|
||||
var $editConfigurationButton = $('<button>', {
|
||||
type: 'button',
|
||||
class: 'fa fa-pencil btn btn-secondary o_edit_product_configuration',
|
||||
tabindex: '-1',
|
||||
draggable: false,
|
||||
'aria-label': _t('Edit Configuration'),
|
||||
title: _t('Edit Configuration')
|
||||
});
|
||||
|
||||
$inputDropdown.after($editConfigurationButton);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Hook to override with _onEditProductConfiguration
|
||||
* to know if edit pencil button has to be put next to the field
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_isConfigurableProduct: function () {
|
||||
return this.recordData.is_configurable_product;
|
||||
},
|
||||
|
||||
/**
|
||||
* Override catching changes on product_id or product_template_id.
|
||||
* Calls _onTemplateChange in case of product_template change.
|
||||
* Calls _onProductChange in case of product change.
|
||||
* Shouldn't be overridden by product configurators
|
||||
* or only to setup some data for further computation
|
||||
* before calling super.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_onFieldChanged: function (ev) {
|
||||
var self = this;
|
||||
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
if (ev.data.changes && ev.data.changes.product_template_id) {
|
||||
self._onTemplateChange(ev.data.changes.product_template_id.id, ev.data.dataPointID);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Hook for product_template based configurators
|
||||
* (product configurator, matrix, ...).
|
||||
*
|
||||
* @param {integer} productTemplateId
|
||||
* @param {String} dataPointID
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onTemplateChange: function (productTemplateId, dataPointId) {
|
||||
var self = this;
|
||||
this._rpc({
|
||||
model: 'product.template',
|
||||
method: 'get_single_product_variant',
|
||||
args: [
|
||||
productTemplateId
|
||||
]
|
||||
}).then(function (result) {
|
||||
if (result.product_id) {
|
||||
self.trigger_up('field_changed', {
|
||||
dataPointID: dataPointId,
|
||||
changes: {
|
||||
product_id: {id: result.product_id},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
self._openMatrix(productTemplateId, dataPointId, false);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Hook for editing a configured line.
|
||||
* The button triggering this function is only shown in Edit mode,
|
||||
* when _isConfigurableProduct is True.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onEditProductConfiguration: function () {
|
||||
if (this.recordData.is_configurable_product) {
|
||||
this._openMatrix(this.recordData.product_template_id.data.id, this.dataPointID, true);
|
||||
}
|
||||
},
|
||||
|
||||
_openMatrix: function (productTemplateId, dataPointId, edit) {
|
||||
var attribs = edit ? this._getPTAVS() : [];
|
||||
this.trigger_up('open_matrix', {
|
||||
product_template_id: productTemplateId,
|
||||
model: 'purchase.order',
|
||||
dataPointId: dataPointId,
|
||||
edit: edit,
|
||||
editedCellAttributes: attribs,
|
||||
// used to focus the cell representing the line on which the pencil was clicked.
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the list of attribute ids (product.template.attribute.value)
|
||||
* from the current POLine.
|
||||
*/
|
||||
_getPTAVS: function () {
|
||||
var PTAVSIDS = [];
|
||||
_.each(this.recordData.product_no_variant_attribute_value_ids.res_ids, function (id) {
|
||||
PTAVSIDS.push(id);
|
||||
});
|
||||
_.each(this.recordData.product_template_attribute_value_ids.res_ids, function (id) {
|
||||
PTAVSIDS.push(id);
|
||||
});
|
||||
return PTAVSIDS.sort(function (a, b) {return a - b;});
|
||||
}
|
||||
});
|
||||
|
||||
FieldsRegistry.add('matrix_configurator', MatrixConfiguratorWidget);
|
||||
|
||||
return MatrixConfiguratorWidget;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<template id="assets_backend_inherit_purchase" inherit_id="web.assets_backend" name="Sale Grid assets">
|
||||
<xpath expr="script[last()]" position="after">
|
||||
<script type="text/javascript" src="/purchase_product_matrix/static/src/js/product_matrix_configurator.js"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- TODO ensure barcode working with matrix (configurator mobile task ?) -->
|
||||
|
||||
<record id="purchase_order_form_matrix" model="ir.ui.view">
|
||||
<field name="name">purchase.order.form.inherit.matrix</field>
|
||||
<field name="model">purchase.order</field>
|
||||
<field name="inherit_id" ref="purchase.purchase_order_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//tree/field[@name='product_id']" position="attributes">
|
||||
<attribute name="invisible">1</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//tree/field[@name='product_id']" position="after">
|
||||
<field name="product_template_id"
|
||||
string="Product"
|
||||
attrs="{
|
||||
'readonly': [('state', 'in', ('purchase', 'to approve','done', 'cancel'))],
|
||||
'required': [('display_type', '=', False)],
|
||||
}"
|
||||
options="{'no_open': True}"
|
||||
context="{'partner_id': parent.partner_id}"
|
||||
widget="matrix_configurator"/>
|
||||
<field name="product_template_attribute_value_ids" invisible="1" />
|
||||
<field name="product_no_variant_attribute_value_ids" invisible="1" />
|
||||
<field name="is_configurable_product" invisible="1" />
|
||||
</xpath>
|
||||
<field name="partner_id" position="after">
|
||||
<field name="grid" invisible="1"/>
|
||||
<field name="grid_product_tmpl_id" invisible="1"/>
|
||||
<field name="grid_update" invisible="1"/>
|
||||
</field>
|
||||
<xpath expr="//group[@name='other_info']" position="inside">
|
||||
<field name="report_grids"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -88,6 +88,7 @@ class ProductAttributeCustomValue(models.Model):
|
||||
_name = "product.attribute.custom.value"
|
||||
_rec_name = 'custom_value'
|
||||
_description = 'Product Attribute Custom Value'
|
||||
_order = 'attribute_value_id, id'
|
||||
|
||||
attribute_value_id = fields.Many2one('product.attribute.value', string='Attribute Value')
|
||||
sale_order_line_id = fields.Many2one('sale.order.line', string='Sale order line')
|
||||
|
||||
@@ -59,51 +59,6 @@ class ProductTemplate(models.Model):
|
||||
}
|
||||
return action
|
||||
|
||||
def _create_product_variant(self, combination, log_warning=False):
|
||||
""" Create if necessary and possible and return the product variant
|
||||
matching the given combination for this template.
|
||||
|
||||
It is possible to create only if the template has dynamic attributes
|
||||
and the combination itself is possible.
|
||||
|
||||
:param combination: the combination for which to get or create variant.
|
||||
The combination must contain all necessary attributes, including
|
||||
those of type no_variant. Indeed even though those attributes won't
|
||||
be included in the variant if newly created, they are needed when
|
||||
checking if the combination is possible.
|
||||
:type combination: recordset of `product.template.attribute.value`
|
||||
|
||||
:param log_warning: whether a warning should be logged on fail
|
||||
:type log_warning: bool
|
||||
|
||||
:return: the product variant matching the combination or none
|
||||
:rtype: recordset of `product.product`
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
Product = self.env['product.product']
|
||||
|
||||
product_variant = self._get_variant_for_combination(combination)
|
||||
if product_variant:
|
||||
return product_variant
|
||||
|
||||
if not self.has_dynamic_attributes():
|
||||
if log_warning:
|
||||
_logger.warning('The user #%s tried to create a variant for the non-dynamic product %s.' % (self.env.user.id, self.id))
|
||||
return Product
|
||||
|
||||
if not self._is_combination_possible(combination):
|
||||
if log_warning:
|
||||
_logger.warning('The user #%s tried to create an invalid variant for the product %s.' % (self.env.user.id, self.id))
|
||||
return Product
|
||||
|
||||
attribute_values = combination.mapped('product_attribute_value_id')._without_no_variant_attributes()
|
||||
|
||||
return Product.sudo().create({
|
||||
'product_tmpl_id': self.id,
|
||||
'attribute_value_ids': [(6, 0, attribute_values.ids)]
|
||||
})
|
||||
|
||||
def create_product_variant(self, product_template_attribute_value_ids):
|
||||
""" Create if necessary and possible and return the id of the product
|
||||
variant matching the given combination for this template.
|
||||
|
||||
@@ -38,6 +38,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="module_sale_product_matrix"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="module_sale_product_matrix"/>
|
||||
<div class="text-muted">
|
||||
Add products to orders using grid view
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="group_uom"/>
|
||||
@@ -92,7 +103,7 @@
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box" title="Boost your sales with two kinds of discount programs: promotions and coupon codes. Specific conditions can be set (products, customers, minimum purchase amount, period). Rewards can be discounts (% or amount) or free products.">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="module_sale_coupon" widget="upgrade_boolean"/>
|
||||
<field name="module_sale_coupon"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="module_sale_coupon"/>
|
||||
|
||||
@@ -12,7 +12,6 @@ class ProductTemplate(models.Model):
|
||||
string='Optional Products', help="Optional Products are suggested "
|
||||
"whenever the customer hits *Add to Cart* (cross-sell strategy, "
|
||||
"e.g. for computers: warranty, software, etc.).")
|
||||
has_configurable_attributes = fields.Boolean("Is a configurable product", compute='_compute_has_configurable_attributes', store=True)
|
||||
|
||||
@api.depends('attribute_line_ids.value_ids.is_custom', 'attribute_line_ids.attribute_id.create_variant')
|
||||
def _compute_has_configurable_attributes(self):
|
||||
@@ -32,17 +31,12 @@ class ProductTemplate(models.Model):
|
||||
- is configurable (see has_configurable_attributes)
|
||||
- has optional products """
|
||||
self.ensure_one()
|
||||
|
||||
if self.product_variant_count == 1 and not self.has_configurable_attributes:
|
||||
res = super(ProductTemplate, self).get_single_product_variant()
|
||||
if res.get('product_id', False):
|
||||
has_optional_products = False
|
||||
for optional_product in self.product_variant_id.optional_product_ids:
|
||||
if optional_product.has_dynamic_attributes() or optional_product._get_possible_variants(self.product_variant_id.product_template_attribute_value_ids):
|
||||
has_optional_products = True
|
||||
break
|
||||
|
||||
return {
|
||||
'product_id': self.product_variant_id.id,
|
||||
'has_optional_products': has_optional_products
|
||||
}
|
||||
|
||||
return None
|
||||
res.update({'has_optional_products': has_optional_products})
|
||||
return res
|
||||
|
||||
@@ -65,7 +65,7 @@ ProductConfiguratorWidget.include({
|
||||
productTemplateId
|
||||
]
|
||||
}).then(function (result) {
|
||||
if (result && !result.has_optional_products) {
|
||||
if (result.product_id && !result.has_optional_products) {
|
||||
self.trigger_up('field_changed', {
|
||||
dataPointID: dataPointId,
|
||||
changes: {
|
||||
@@ -76,15 +76,8 @@ ProductConfiguratorWidget.include({
|
||||
},
|
||||
});
|
||||
} else {
|
||||
self._openProductConfigurator({
|
||||
configuratorMode: (result && result.has_optional_products ? 'options' : 'add'),
|
||||
default_pricelist_id: self._getPricelistId(),
|
||||
default_product_template_id: productTemplateId
|
||||
},
|
||||
dataPointId
|
||||
);
|
||||
return self._openConfigurator(result, productTemplateId, dataPointId);
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
// always returns true for the moment because no other configurator exists.
|
||||
});
|
||||
},
|
||||
@@ -115,6 +108,20 @@ ProductConfiguratorWidget.include({
|
||||
}
|
||||
},
|
||||
|
||||
_openConfigurator: function (result, productTemplateId, dataPointId) {
|
||||
if (!result.mode || result.mode === 'configurator') {
|
||||
this._openProductConfigurator({
|
||||
configuratorMode: result && result.has_optional_products ? 'options' : 'add',
|
||||
default_pricelist_id: this._getPricelistId(),
|
||||
default_product_template_id: productTemplateId
|
||||
},
|
||||
dataPointId
|
||||
);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens the product configurator to allow configuring the product template
|
||||
* and its various options.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from . import models
|
||||
@@ -0,0 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
{
|
||||
'name': "Sale Matrix",
|
||||
'summary': """
|
||||
Add variants to Sales Order through a grid entry.
|
||||
""",
|
||||
'description': """
|
||||
This module allows to fill Sales Order rapidly
|
||||
by choosing product variants quantity through a Grid Entry.
|
||||
""",
|
||||
'category': 'Sales',
|
||||
'version': '1.0',
|
||||
'depends': ['sale', 'product_matrix', 'sale_product_configurator'],
|
||||
'data': [
|
||||
'views/assets.xml',
|
||||
'views/product_template_views.xml',
|
||||
'views/sale_views.xml',
|
||||
'report/sale_report_templates.xml',
|
||||
],
|
||||
'demo': [
|
||||
'data/product_matrix_demo.xml'
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<record id="product_matrix.matrix_product_template_shirt" model="product.template">
|
||||
<field name="product_add_mode">matrix</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from . import sale_order
|
||||
from . import product_template
|
||||
@@ -0,0 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from odoo import api, models, fields
|
||||
|
||||
|
||||
class ProductTemplate(models.Model):
|
||||
_inherit = 'product.template'
|
||||
|
||||
product_add_mode = fields.Selection([
|
||||
('configurator', 'Product Configurator'),
|
||||
('matrix', 'Order Grid Entry'),
|
||||
], string='Add product mode', default='configurator', help="Configurator: choose attribute values to add the matching \
|
||||
product variant to the order.\n Matrix: add several variant at once from the grid of attribute values")
|
||||
|
||||
def get_single_product_variant(self):
|
||||
res = super(ProductTemplate, self).get_single_product_variant()
|
||||
res['mode'] = self.product_add_mode
|
||||
return res
|
||||
@@ -0,0 +1,157 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import json
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
report_grids = fields.Boolean(
|
||||
string="Show grids on reports", default=True,
|
||||
help="If set, the matrix of the products configurable by matrix will be shown on the report of the order.")
|
||||
|
||||
""" Matrix loading and update: fields and methods :
|
||||
|
||||
NOTE: The matrix functionality was done in python, server side, to avoid js
|
||||
restriction. Indeed, the js framework only loads the x first lines displayed
|
||||
in the client, which means in case of big matrices and lots of so_lines,
|
||||
the js doesn't have access to the 41nth and following lines.
|
||||
|
||||
To force the loading, a 'hack' of the js framework would have been needed...
|
||||
"""
|
||||
|
||||
grid_product_tmpl_id = fields.Many2one(
|
||||
'product.template', store=False,
|
||||
help="Technical field for product_matrix functionalities.")
|
||||
grid_update = fields.Boolean(
|
||||
default=False, store=False,
|
||||
help="Whether the grid field contains a new matrix to apply or not.")
|
||||
grid = fields.Char(
|
||||
"Matrix local storage", store=False,
|
||||
help="Technical local storage of grid. \nIf grid_update, will be loaded on the SO. \nIf not, represents the matrix to open.")
|
||||
|
||||
@api.onchange('grid_product_tmpl_id')
|
||||
def _set_grid_up(self):
|
||||
"""Save locally the matrix of the given product.template, to be used by the matrix configurator."""
|
||||
if self.grid_product_tmpl_id:
|
||||
self.grid_update = False
|
||||
self.grid = json.dumps(self._get_matrix(self.grid_product_tmpl_id))
|
||||
|
||||
@api.onchange('grid')
|
||||
def _apply_grid(self):
|
||||
"""Apply the given list of changed matrix cells to the current SO."""
|
||||
if self.grid and self.grid_update:
|
||||
grid = json.loads(self.grid)
|
||||
product_template = self.env['product.template'].browse(grid['product_template_id'])
|
||||
dirty_cells = grid['changes']
|
||||
Attrib = self.env['product.template.attribute.value']
|
||||
default_so_line_vals = {}
|
||||
new_lines = []
|
||||
for cell in dirty_cells:
|
||||
combination = Attrib.browse(cell['ptav_ids'])
|
||||
no_variant_attribute_values = combination - combination._without_no_variant_attributes()
|
||||
|
||||
# create or find product variant from combination
|
||||
product = product_template._create_product_variant(combination)
|
||||
order_lines = self.order_line.filtered(lambda line: line.product_id == product and line.product_no_variant_attribute_value_ids == no_variant_attribute_values)
|
||||
|
||||
# if product variant already exist in order lines
|
||||
old_qty = sum(order_lines.mapped('product_uom_qty'))
|
||||
qty = cell['qty']
|
||||
diff = qty - old_qty
|
||||
# TODO keep qty check? cannot be 0 because we only get cell changes ...
|
||||
if diff and order_lines:
|
||||
if qty == 0:
|
||||
if self.state in ['draft', 'sent']:
|
||||
# Remove lines if qty was set to 0 in matrix
|
||||
# only if SO state = draft/sent
|
||||
self.order_line -= order_lines
|
||||
else:
|
||||
order_lines.update({'product_uom_qty': 0.0})
|
||||
else:
|
||||
"""
|
||||
When there are multiple lines for same product and its quantity was changed in the matrix,
|
||||
An error is raised.
|
||||
|
||||
A 'good' strategy would be to:
|
||||
* Sets the quantity of the first found line to the cell value
|
||||
* Remove the other lines.
|
||||
|
||||
But this would remove all business logic linked to the other lines...
|
||||
Therefore, it only raises an Error for now.
|
||||
"""
|
||||
if len(order_lines) > 1:
|
||||
raise ValidationError(_("You cannot change the quantity of a product present in multiple purchase lines."))
|
||||
else:
|
||||
order_lines[0].product_uom_qty = qty
|
||||
# If we want to support multiple lines edition:
|
||||
# removal of other lines.
|
||||
# For now, an error is raised instead
|
||||
# if len(order_lines) > 1:
|
||||
# # Remove 1+ lines
|
||||
# self.order_line -= order_lines[1:]
|
||||
elif diff and not order_lines:
|
||||
if not default_so_line_vals:
|
||||
OrderLine = self.env['sale.order.line']
|
||||
default_so_line_vals = OrderLine.default_get(OrderLine._fields.keys())
|
||||
pnvav = [] # product no variant attribute values
|
||||
for nvav in no_variant_attribute_values:
|
||||
pnvav.append((4, nvav))
|
||||
new_lines.append((0, 0, dict(
|
||||
default_so_line_vals,
|
||||
product_id=product.id,
|
||||
product_uom_qty=qty,
|
||||
product_no_variant_attribute_value_ids=pnvav)
|
||||
))
|
||||
if new_lines:
|
||||
self.update(dict(order_line=new_lines))
|
||||
for line in self.order_line.filtered(lambda line: line.product_template_id == product_template):
|
||||
line.product_id_change()
|
||||
|
||||
def _get_matrix(self, product_template):
|
||||
"""Return the matrix of the given product, updated with current SOLines quantities.
|
||||
|
||||
:param product.template product_template:
|
||||
:return: matrix to display
|
||||
:rtype dict:
|
||||
"""
|
||||
def has_ptavs(line, sorted_attr_ids):
|
||||
# TODO instead of sorting on ids, use odoo-defined order for matrix ?
|
||||
ptav = line.product_template_attribute_value_ids.ids
|
||||
pnav = line.product_no_variant_attribute_value_ids.ids
|
||||
pav = pnav + ptav
|
||||
pav.sort()
|
||||
return pav == sorted_attr_ids
|
||||
matrix = product_template._get_template_matrix(
|
||||
company_id=self.company_id,
|
||||
currency_id=self.currency_id,
|
||||
display_extra_price=True)
|
||||
if self.order_line:
|
||||
lines = matrix['matrix']
|
||||
order_lines = self.order_line.filtered(lambda line: line.product_template_id == product_template)
|
||||
for line in lines:
|
||||
for cell in line:
|
||||
if not cell.get('name', False):
|
||||
line = order_lines.filtered(lambda line: has_ptavs(line, cell['ptav_ids']))
|
||||
if line:
|
||||
cell.update({
|
||||
'qty': sum(line.mapped('product_uom_qty'))
|
||||
})
|
||||
return matrix
|
||||
|
||||
def get_report_matrixes(self):
|
||||
"""Reporting method.
|
||||
|
||||
:return: array of matrices to display in the report
|
||||
:rtype: list
|
||||
"""
|
||||
matrixes = []
|
||||
if self.report_grids:
|
||||
grid_configured_templates = self.order_line.filtered('is_configurable_product').product_template_id.filtered(lambda ptmpl: ptmpl.product_add_mode == 'matrix')
|
||||
for template in grid_configured_templates:
|
||||
if len(self.order_line.filtered(lambda line: line.product_template_id == template)) > 1:
|
||||
# TODO do we really want the whole matrix even if there isn't a lot of lines ??
|
||||
matrixes.append(self._get_matrix(template))
|
||||
return matrixes
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="grid_report_saleorder_inherit" inherit_id="sale.report_saleorder_document">
|
||||
<xpath expr="//table[hasclass('o_main_table')]" position="before">
|
||||
<t t-call="product_matrix.matrix">
|
||||
<t t-set="order" t-value="doc"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -0,0 +1,97 @@
|
||||
odoo.define('sale_product_matrix.product_configurator', function (require) {
|
||||
var ProductConfiguratorWidget = require('sale_product_configurator.product_configurator');
|
||||
|
||||
/**
|
||||
* Extension of the ProductConfiguratorWidget to support product configuration
|
||||
* as variant batches to add to the SO..
|
||||
* It opens when a configurable product_template is set
|
||||
* (multiple variants, or custom attributes)
|
||||
* and its configuration mode is matrix.
|
||||
*
|
||||
*/
|
||||
ProductConfiguratorWidget.include({
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_openConfigurator: function (result, productTemplateId, dataPointId) {
|
||||
var self = this;
|
||||
var mode = result.mode;
|
||||
this._super.apply(this, arguments).then(function (configuratorOpened) {
|
||||
if (!configuratorOpened && mode === 'matrix') {
|
||||
self._openGridConfigurator(productTemplateId, dataPointId);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return Promise.resolve(configuratorOpened);
|
||||
});
|
||||
},
|
||||
|
||||
_openGridConfigurator: function (productTemplateId, dataPointId, edit) {
|
||||
var attribs = edit ? this._getPTAVS() : [];
|
||||
this.trigger_up('open_matrix', {
|
||||
product_template_id: productTemplateId,
|
||||
model: 'sale.order',
|
||||
dataPointId: dataPointId,
|
||||
edit: edit,
|
||||
editedCellAttributes: attribs,
|
||||
});
|
||||
},
|
||||
|
||||
_onEditProductConfiguration: function () {
|
||||
if (!this.recordData.is_configurable_product) {
|
||||
// if line should be edited by another configurator
|
||||
// or simply inline.
|
||||
this._super.apply(this, arguments);
|
||||
return;
|
||||
}
|
||||
var self = this;
|
||||
var productTemplateId = this.recordData.product_template_id.data.id;
|
||||
this._rpc({
|
||||
model: 'product.template',
|
||||
method: 'read',
|
||||
args: [productTemplateId, ['product_add_mode']],
|
||||
}).then(function (result) {
|
||||
if (result && result[0].product_add_mode === 'matrix') {
|
||||
self._openGridConfigurator(productTemplateId, self.dataPointID, true);
|
||||
} else {
|
||||
// Call super only if product_add_mode different than matrix
|
||||
// to avoid product configurator opening (which is the default case).
|
||||
self._openProductConfigurator({
|
||||
configuratorMode: 'edit',
|
||||
default_product_template_id: self.recordData.product_template_id.data.id,
|
||||
default_pricelist_id: self._getPricelistId(),
|
||||
default_product_template_attribute_value_ids: self._convertFromMany2Many(
|
||||
self.recordData.product_template_attribute_value_ids
|
||||
),
|
||||
default_product_no_variant_attribute_value_ids: self._convertFromMany2Many(
|
||||
self.recordData.product_no_variant_attribute_value_ids
|
||||
),
|
||||
default_product_custom_attribute_value_ids: self._convertFromOne2Many(
|
||||
self.recordData.product_custom_attribute_value_ids
|
||||
),
|
||||
default_quantity: self.recordData.product_uom_qty
|
||||
},
|
||||
self.dataPointID
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the list of attribute ids (product.template.attribute.value)
|
||||
* from the current SOLine.
|
||||
*/
|
||||
_getPTAVS: function () {
|
||||
var PTAVSIDS = [];
|
||||
_.each(this.recordData.product_no_variant_attribute_value_ids.res_ids, function (id) {
|
||||
PTAVSIDS.push(id);
|
||||
});
|
||||
_.each(this.recordData.product_template_attribute_value_ids.res_ids, function (id) {
|
||||
PTAVSIDS.push(id);
|
||||
});
|
||||
return PTAVSIDS.sort(function (a, b) {return a - b;});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="assets_backend_inherit_sale" inherit_id="web.assets_backend" name="Sale Grid assets">
|
||||
<xpath expr="script[last()]" position="after">
|
||||
<script type="text/javascript" src="/sale_product_matrix/static/src/js/product_matrix_configurator.js"/>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="product_template_grid_view_form" model="ir.ui.view">
|
||||
<field name="name">product.template.form.inherit.sale.product.matrix</field>
|
||||
<field name="model">product.template</field>
|
||||
<field name="inherit_id" ref="product.product_template_only_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//page[@name='variants']" position="inside">
|
||||
<group name="product_mode" attrs="{'invisible': [('has_configurable_attributes', '=', False)]}">
|
||||
<group string="How to select a variant in the sale order">
|
||||
<field name="has_configurable_attributes" invisible="1"/>
|
||||
<field name="product_add_mode" widget="radio" nolabel="1"/>
|
||||
</group>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="product_template_view_form" model="ir.ui.view">
|
||||
<field name="name">product.template.form.inherit</field>
|
||||
<field name="model">product.template</field>
|
||||
<field name="inherit_id" ref="sale_product_configurator.product_template_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group[@name='options']" position="inside">
|
||||
<field name="product_add_mode" widget="radio" invisible="1"/>
|
||||
<!--
|
||||
Field is only needed as invisible on product variants view
|
||||
-->
|
||||
</xpath>
|
||||
<xpath expr="//group[@name='options']" position="attributes">
|
||||
<!-- HIDE optional products many2many when product is added through the grid to SOLines -->
|
||||
<attribute name="attrs">{'invisible': [('product_add_mode', '=', 'grid')]}</attribute>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<record id="view_order_form_with_variant_grid" model="ir.ui.view">
|
||||
<field name="name">sale.order.line.variant.grid</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="partner_id" position="after">
|
||||
<!-- Technical non stored fields for Order Grid Entry -->
|
||||
<field name="grid" invisible="1"/>
|
||||
<field name="grid_product_tmpl_id" invisible="1"/>
|
||||
<field name="grid_update" invisible="1"/>
|
||||
</field>
|
||||
<xpath expr="//notebook//group[@name='sale_info']" position="inside">
|
||||
<field name="report_grids"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -30,7 +30,8 @@ var Dialog = Widget.extend({
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.title=Odoo]
|
||||
* @param {string} [options.subtitle]
|
||||
* @param {string} [options.size=large] - 'large', 'medium' or 'small'
|
||||
* @param {string} [options.size=large] - 'extra-large', 'large', 'medium'
|
||||
* or 'small'
|
||||
* @param {boolean} [options.fullscreen=false] - whether or not the dialog
|
||||
* should be open in fullscreen mode (the main usecase is mobile)
|
||||
* @param {string} [options.dialogClass] - class to add to the modal-body
|
||||
@@ -114,6 +115,9 @@ var Dialog = Widget.extend({
|
||||
renderFooter: self.renderFooter,
|
||||
}));
|
||||
switch (self.size) {
|
||||
case 'extra-large':
|
||||
self.$modal.find('.modal-dialog').addClass('modal-xl');
|
||||
break;
|
||||
case 'large':
|
||||
self.$modal.find('.modal-dialog').addClass('modal-lg');
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user