summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--addons/product/models/res_config_settings.py10
-rw-r--r--addons/product_matrix/__init__.py4
-rw-r--r--addons/product_matrix/__manifest__.py25
-rw-r--r--addons/product_matrix/data/product_matrix_demo.xml135
-rw-r--r--addons/product_matrix/models/__init__.py1
-rw-r--r--addons/product_matrix/models/product_template.py92
-rw-r--r--addons/product_matrix/static/img/matrix_mycompany_tshirt.jpegbin0 -> 111742 bytes
-rw-r--r--addons/product_matrix/static/src/js/section_and_note_widget.js139
-rw-r--r--addons/product_matrix/static/src/scss/product_matrix.scss93
-rw-r--r--addons/product_matrix/static/src/xml/product_matrix.xml51
-rw-r--r--addons/product_matrix/views/assets.xml9
-rw-r--r--addons/product_matrix/views/matrix_templates.xml51
-rw-r--r--addons/purchase/models/res_config_settings.py1
-rw-r--r--addons/purchase/views/res_config_settings_views.xml11
-rw-r--r--addons/purchase_product_matrix/__init__.py3
-rw-r--r--addons/purchase_product_matrix/__manifest__.py21
-rw-r--r--addons/purchase_product_matrix/models/__init__.py3
-rw-r--r--addons/purchase_product_matrix/models/purchase.py148
-rw-r--r--addons/purchase_product_matrix/report/purchase_order_templates.xml10
-rw-r--r--addons/purchase_product_matrix/report/purchase_quotation_templates.xml10
-rw-r--r--addons/purchase_product_matrix/static/src/js/product_matrix_configurator.js187
-rw-r--r--addons/purchase_product_matrix/views/assets.xml10
-rw-r--r--addons/purchase_product_matrix/views/purchase_views.xml39
-rw-r--r--addons/sale/views/res_config_settings_views.xml11
-rw-r--r--addons/sale_product_matrix/__init__.py3
-rw-r--r--addons/sale_product_matrix/__manifest__.py24
-rw-r--r--addons/sale_product_matrix/data/product_matrix_demo.xml6
-rw-r--r--addons/sale_product_matrix/models/__init__.py4
-rw-r--r--addons/sale_product_matrix/models/product_template.py18
-rw-r--r--addons/sale_product_matrix/models/sale_order.py157
-rw-r--r--addons/sale_product_matrix/report/sale_report_templates.xml10
-rw-r--r--addons/sale_product_matrix/static/src/js/product_matrix_configurator.js97
-rw-r--r--addons/sale_product_matrix/views/assets.xml8
-rw-r--r--addons/sale_product_matrix/views/product_template_views.xml36
-rw-r--r--addons/sale_product_matrix/views/sale_views.xml19
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
new file mode 100644
index 00000000000..b600989d3c2
--- /dev/null
+++ b/addons/product_matrix/static/img/matrix_mycompany_tshirt.jpeg
Binary files 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 @@
+<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>