diff options
35 files changed, 1446 insertions, 0 deletions
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 @@ +<?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> 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 Binary files differnew file mode 100644 index 00000000000..b600989d3c2 --- /dev/null +++ b/addons/product_matrix/static/img/matrix_mycompany_tshirt.jpeg 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 @@ +<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> 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 @@ +<?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> diff --git a/addons/product_matrix/views/matrix_templates.xml b/addons/product_matrix/views/matrix_templates.xml new file mode 100644 index 00000000000..e4fda48768e --- /dev/null +++ b/addons/product_matrix/views/matrix_templates.xml @@ -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> diff --git a/addons/purchase/models/res_config_settings.py b/addons/purchase/models/res_config_settings.py index 18b00b75fe7..e948eecde18 100644 --- a/addons/purchase/models/res_config_settings.py +++ b/addons/purchase/models/res_config_settings.py @@ -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", diff --git a/addons/purchase/views/res_config_settings_views.xml b/addons/purchase/views/res_config_settings_views.xml index f3b31f193b8..373f1b91f20 100644 --- a/addons/purchase/views/res_config_settings_views.xml +++ b/addons/purchase/views/res_config_settings_views.xml @@ -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> diff --git a/addons/purchase_product_matrix/__init__.py b/addons/purchase_product_matrix/__init__.py new file mode 100644 index 00000000000..be9f4fab00a --- /dev/null +++ b/addons/purchase_product_matrix/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import models diff --git a/addons/purchase_product_matrix/__manifest__.py b/addons/purchase_product_matrix/__manifest__.py new file mode 100644 index 00000000000..269668e5c3b --- /dev/null +++ b/addons/purchase_product_matrix/__manifest__.py @@ -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', + ], +} diff --git a/addons/purchase_product_matrix/models/__init__.py b/addons/purchase_product_matrix/models/__init__.py new file mode 100644 index 00000000000..c05845f2ec5 --- /dev/null +++ b/addons/purchase_product_matrix/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import purchase diff --git a/addons/purchase_product_matrix/models/purchase.py b/addons/purchase_product_matrix/models/purchase.py new file mode 100644 index 00000000000..a319bbdd9cb --- /dev/null +++ b/addons/purchase_product_matrix/models/purchase.py @@ -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') diff --git a/addons/purchase_product_matrix/report/purchase_order_templates.xml b/addons/purchase_product_matrix/report/purchase_order_templates.xml new file mode 100644 index 00000000000..91a1d0773c8 --- /dev/null +++ b/addons/purchase_product_matrix/report/purchase_order_templates.xml @@ -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> diff --git a/addons/purchase_product_matrix/report/purchase_quotation_templates.xml b/addons/purchase_product_matrix/report/purchase_quotation_templates.xml new file mode 100644 index 00000000000..a00c15bceeb --- /dev/null +++ b/addons/purchase_product_matrix/report/purchase_quotation_templates.xml @@ -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> diff --git a/addons/purchase_product_matrix/static/src/js/product_matrix_configurator.js b/addons/purchase_product_matrix/static/src/js/product_matrix_configurator.js new file mode 100644 index 00000000000..9d1175d2a1d --- /dev/null +++ b/addons/purchase_product_matrix/static/src/js/product_matrix_configurator.js @@ -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; + +}); diff --git a/addons/purchase_product_matrix/views/assets.xml b/addons/purchase_product_matrix/views/assets.xml new file mode 100644 index 00000000000..38a6a74bb37 --- /dev/null +++ b/addons/purchase_product_matrix/views/assets.xml @@ -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> diff --git a/addons/purchase_product_matrix/views/purchase_views.xml b/addons/purchase_product_matrix/views/purchase_views.xml new file mode 100644 index 00000000000..ecb92de65df --- /dev/null +++ b/addons/purchase_product_matrix/views/purchase_views.xml @@ -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> diff --git a/addons/sale/views/res_config_settings_views.xml b/addons/sale/views/res_config_settings_views.xml index 6f67fab1d6e..702e54f36fc 100644 --- a/addons/sale/views/res_config_settings_views.xml +++ b/addons/sale/views/res_config_settings_views.xml @@ -40,6 +40,17 @@ </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"/> </div> <div class="o_setting_right_pane"> diff --git a/addons/sale_product_matrix/__init__.py b/addons/sale_product_matrix/__init__.py new file mode 100644 index 00000000000..be9f4fab00a --- /dev/null +++ b/addons/sale_product_matrix/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import models diff --git a/addons/sale_product_matrix/__manifest__.py b/addons/sale_product_matrix/__manifest__.py new file mode 100644 index 00000000000..6dbbf899b2d --- /dev/null +++ b/addons/sale_product_matrix/__manifest__.py @@ -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' + ] +} diff --git a/addons/sale_product_matrix/data/product_matrix_demo.xml b/addons/sale_product_matrix/data/product_matrix_demo.xml new file mode 100644 index 00000000000..be1831266c6 --- /dev/null +++ b/addons/sale_product_matrix/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> diff --git a/addons/sale_product_matrix/models/__init__.py b/addons/sale_product_matrix/models/__init__.py new file mode 100644 index 00000000000..f2d32ff9c24 --- /dev/null +++ b/addons/sale_product_matrix/models/__init__.py @@ -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 diff --git a/addons/sale_product_matrix/models/product_template.py b/addons/sale_product_matrix/models/product_template.py new file mode 100644 index 00000000000..5993e51ebeb --- /dev/null +++ b/addons/sale_product_matrix/models/product_template.py @@ -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 diff --git a/addons/sale_product_matrix/models/sale_order.py b/addons/sale_product_matrix/models/sale_order.py new file mode 100644 index 00000000000..724b87f2dfc --- /dev/null +++ b/addons/sale_product_matrix/models/sale_order.py @@ -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 diff --git a/addons/sale_product_matrix/report/sale_report_templates.xml b/addons/sale_product_matrix/report/sale_report_templates.xml new file mode 100644 index 00000000000..c419c442543 --- /dev/null +++ b/addons/sale_product_matrix/report/sale_report_templates.xml @@ -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> diff --git a/addons/sale_product_matrix/static/src/js/product_matrix_configurator.js b/addons/sale_product_matrix/static/src/js/product_matrix_configurator.js new file mode 100644 index 00000000000..7ea0021053c --- /dev/null +++ b/addons/sale_product_matrix/static/src/js/product_matrix_configurator.js @@ -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;}); + } + +}); + +}); diff --git a/addons/sale_product_matrix/views/assets.xml b/addons/sale_product_matrix/views/assets.xml new file mode 100644 index 00000000000..780fb0f3f83 --- /dev/null +++ b/addons/sale_product_matrix/views/assets.xml @@ -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> diff --git a/addons/sale_product_matrix/views/product_template_views.xml b/addons/sale_product_matrix/views/product_template_views.xml new file mode 100644 index 00000000000..a82a82fffae --- /dev/null +++ b/addons/sale_product_matrix/views/product_template_views.xml @@ -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> diff --git a/addons/sale_product_matrix/views/sale_views.xml b/addons/sale_product_matrix/views/sale_views.xml new file mode 100644 index 00000000000..cdcc530cd5a --- /dev/null +++ b/addons/sale_product_matrix/views/sale_views.xml @@ -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> |
