diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index fd05e009dc4..17e8594b006 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -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( diff --git a/addons/product/models/res_config_settings.py b/addons/product/models/res_config_settings.py index 2ebc411fb49..6ff32bd8f6b 100644 --- a/addons/product/models/res_config_settings.py +++ b/addons/product/models/res_config_settings.py @@ -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 diff --git a/addons/product_matrix/__init__.py b/addons/product_matrix/__init__.py new file mode 100644 index 00000000000..dc5e6b693d1 --- /dev/null +++ b/addons/product_matrix/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/product_matrix/__manifest__.py b/addons/product_matrix/__manifest__.py new file mode 100644 index 00000000000..801e3700250 --- /dev/null +++ b/addons/product_matrix/__manifest__.py @@ -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', + ] +} diff --git a/addons/product_matrix/data/product_matrix_demo.xml b/addons/product_matrix/data/product_matrix_demo.xml new file mode 100644 index 00000000000..c388edcb252 --- /dev/null +++ b/addons/product_matrix/data/product_matrix_demo.xml @@ -0,0 +1,135 @@ + + + + Size + + + XS + + 1 + + + S + + 2 + + + M + + 3 + + + L + + 4 + + + XL + + 5 + + + 2XL + + 6 + + + 3XL + + 7 + + + + Gender + + + Men + + 1 + + + Women + + 2 + + + + Sleeves + + + Short Sleeves + + 1 + + + Long Sleeves + + 2 + + + + + Blue + + 3 + + + Pink + + 4 + + + Yellow + + 5 + + + Rainbow + + 6 + + + + My Company Tshirt + + GRID + 7.0 + 15.0 + consu + + + Show your company love around you =). + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/product_matrix/models/__init__.py b/addons/product_matrix/models/__init__.py new file mode 100644 index 00000000000..e8fa8f6bf1e --- /dev/null +++ b/addons/product_matrix/models/__init__.py @@ -0,0 +1 @@ +from . import product_template diff --git a/addons/product_matrix/models/product_template.py b/addons/product_matrix/models/product_template.py new file mode 100644 index 00000000000..64b164e51e6 --- /dev/null +++ b/addons/product_matrix/models/product_template.py @@ -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 diff --git a/addons/product_matrix/static/img/matrix_mycompany_tshirt.jpeg b/addons/product_matrix/static/img/matrix_mycompany_tshirt.jpeg new file mode 100644 index 00000000000..b600989d3c2 Binary files /dev/null and b/addons/product_matrix/static/img/matrix_mycompany_tshirt.jpeg differ diff --git a/addons/product_matrix/static/src/js/section_and_note_widget.js b/addons/product_matrix/static/src/js/section_and_note_widget.js new file mode 100644 index 00000000000..98c471833d3 --- /dev/null +++ b/addons/product_matrix/static/src/js/section_and_note_widget.js @@ -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; + +}); diff --git a/addons/product_matrix/static/src/scss/product_matrix.scss b/addons/product_matrix/static/src/scss/product_matrix.scss new file mode 100644 index 00000000000..cdd7d7cd31c --- /dev/null +++ b/addons/product_matrix/static/src/scss/product_matrix.scss @@ -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; + } + } +} diff --git a/addons/product_matrix/static/src/xml/product_matrix.xml b/addons/product_matrix/static/src/xml/product_matrix.xml new file mode 100644 index 00000000000..dde66ee22ee --- /dev/null +++ b/addons/product_matrix/static/src/xml/product_matrix.xml @@ -0,0 +1,51 @@ + diff --git a/addons/product_matrix/views/assets.xml b/addons/product_matrix/views/assets.xml new file mode 100644 index 00000000000..7f0793f8904 --- /dev/null +++ b/addons/product_matrix/views/assets.xml @@ -0,0 +1,9 @@ + + +