diff --git a/sale_line_reconfigure/__init__.py b/sale_line_reconfigure/__init__.py new file mode 100755 index 00000000..91c5580f --- /dev/null +++ b/sale_line_reconfigure/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/sale_line_reconfigure/__manifest__.py b/sale_line_reconfigure/__manifest__.py new file mode 100755 index 00000000..56f745df --- /dev/null +++ b/sale_line_reconfigure/__manifest__.py @@ -0,0 +1,29 @@ +{ + 'name': 'Sale Line Reconfigure', + 'author': 'Hibou Corp. ', + 'category': 'Hidden', + 'version': '12.0.1.0.0', + 'description': + """ +Sale Line Reconfigure +===================== + +The product configurator allows for truely complex sale order lines, with potentially +no-variant and custom attribute values. + +This module allows you to create 'duplicate' sale order lines that start with all of +the attribute values from some other line. This lets you treat existing SO lines as +templates for the new additions. This lets you "re-configure" a line by a +workflow in which you add a new line, then remove old line (or reduce its ordered quantity). + """, + 'depends': [ + 'sale', + 'account', + ], + 'auto_install': False, + 'data': [ + 'views/assets.xml', + 'views/product_views.xml', + 'views/sale_product_configurator_templates.xml', + ], +} diff --git a/sale_line_reconfigure/controllers/__init__.py b/sale_line_reconfigure/controllers/__init__.py new file mode 100644 index 00000000..3c76586e --- /dev/null +++ b/sale_line_reconfigure/controllers/__init__.py @@ -0,0 +1 @@ +from . import product_configurator diff --git a/sale_line_reconfigure/controllers/product_configurator.py b/sale_line_reconfigure/controllers/product_configurator.py new file mode 100644 index 00000000..c7622976 --- /dev/null +++ b/sale_line_reconfigure/controllers/product_configurator.py @@ -0,0 +1,32 @@ +from odoo import http, fields +from odoo.addons.sale.controllers import product_configurator +from odoo.http import request + + + +class ProductConfiguratorController(product_configurator.ProductConfiguratorController): + @http.route(['/product_configurator/configure'], type='json', auth="user", methods=['POST']) + def configure(self, product_id, pricelist_id, sale_line_id=None, **kw): + product_template = request.env['product.template'].browse(int(product_id)) + to_currency = product_template.currency_id + pricelist = self._get_pricelist(pricelist_id) + if pricelist: + product_template = product_template.with_context(pricelist=pricelist.id, partner=request.env.user.partner_id) + to_currency = pricelist.currency_id + + sale_line = None + if sale_line_id: + sale_line = request.env['sale.order.line'].browse(int(sale_line_id)) + + return request.env['ir.ui.view'].render_template("sale.product_configurator_configure", { + 'product': product_template, + 'to_currency': to_currency, + 'pricelist': pricelist, + 'sale_line': sale_line, + 'get_attribute_exclusions': self._get_attribute_exclusions, + 'get_attribute_value_defaults': self._get_attribute_value_defaults, + 'sale_line': sale_line, + }) + + def _get_attribute_value_defaults(self, product, sale_line, **kw): + return product.get_default_attribute_values(sale_line) diff --git a/sale_line_reconfigure/models/__init__.py b/sale_line_reconfigure/models/__init__.py new file mode 100644 index 00000000..23275437 --- /dev/null +++ b/sale_line_reconfigure/models/__init__.py @@ -0,0 +1 @@ +from . import product \ No newline at end of file diff --git a/sale_line_reconfigure/models/product.py b/sale_line_reconfigure/models/product.py new file mode 100644 index 00000000..9b1a5c88 --- /dev/null +++ b/sale_line_reconfigure/models/product.py @@ -0,0 +1,47 @@ +from odoo import fields, models + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + def get_default_attribute_values(self, so_line): + product = None + if so_line: + product = so_line.product_id + attribute_values = self.env['product.attribute.value'].browse() + for attribute_line in self.attribute_line_ids: + attribute = attribute_line.attribute_id + attribute_line_values = attribute_line.product_template_value_ids + + # Product Values + if product: + product_values = product.attribute_value_ids.filtered(lambda v: v.attribute_id == attribute) + if product_values: + attribute_values += product_values + continue + + so_line_values = so_line.product_no_variant_attribute_value_ids.filtered( + lambda v: v.attribute_id == attribute) + if so_line_values: + attribute_values += so_line_values.mapped('product_attribute_value_id') + continue + + default_value = self.env['product.template.attribute.value'].search([ + ('product_tmpl_id', '=', self.id), + ('attribute_id', '=', attribute.id), + ('is_default', '=', True), + ], limit=1) + if default_value: + attribute_values += default_value.mapped('product_attribute_value_id') + continue + + # First value + attribute_values += attribute_line_values[0].product_attribute_value_id + + return attribute_values + + +class ProductTemplateAttributeValue(models.Model): + _inherit = 'product.template.attribute.value' + + is_default = fields.Boolean(string='Use as Default', copy=False) diff --git a/sale_line_reconfigure/static/src/js/product_configurator_controller.js b/sale_line_reconfigure/static/src/js/product_configurator_controller.js new file mode 100644 index 00000000..32f2f427 --- /dev/null +++ b/sale_line_reconfigure/static/src/js/product_configurator_controller.js @@ -0,0 +1,198 @@ +odoo.define('sale_line_reconfigure.ProductConfiguratorFormController', function (require) { +"use strict"; + +/* +Product Configurator is very powerful, but not so easy to extend. + */ + +var core = require('web.core'); +var _t = core._t; +var FormController = require('web.FormController'); +var OptionalProductsModal = require('sale.OptionalProductsModal'); + +var ProductConfiguratorFormController = FormController.extend({ + custom_events: _.extend({}, FormController.prototype.custom_events, { + field_changed: '_onFieldChanged' + }), + className: 'o_product_configurator', + /** + * @override + */ + init: function (){ + this._super.apply(this, arguments); + }, + /** + * We need to override the default click behavior for our "Add" button + * because there is a possibility that this product has optional products. + * If so, we need to display an extra modal to choose the options. + * + * @override + */ + _onButtonClicked: function (event) { + if (event.stopPropagation){ + event.stopPropagation(); + } + var attrs = event.data.attrs; + if (attrs.special === 'cancel') { + this._super.apply(this, arguments); + } else { + if (!this.$el + .parents('.modal') + .find('.o_sale_product_configurator_add') + .hasClass('disabled')){ + this._handleAdd(this.$el); + } + } + }, + /** + * This is overridden to allow catching the "select" event on our product template select field. + * This will not work anymore if more fields are added to the form. + * TODO awa: Find a better way to catch that event. + * + * @override + */ + _onFieldChanged: function (event) { + var self = this; + + this.$el.parents('.modal').find('.o_sale_product_configurator_add').removeClass('disabled'); + + this._rpc({ + route: '/product_configurator/configure', + params: { + product_id: event.data.changes.product_template_id.id, + pricelist_id: this.renderer.pricelistId, + sale_line_id: this.renderer.saleLineId, + } + }).then(function (configurator) { + self.renderer.renderConfigurator(configurator); + }); + + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * When the user adds a product that has optional products, we need to display + * a window to allow the user to choose these extra options. + * + * This will also create the product if it's in "dynamic" mode + * (see product_attribute.create_variant) + * + * @private + * @param {$.Element} $modal + */ + _handleAdd: function ($modal) { + var self = this; + var productSelector = [ + 'input[type="hidden"][name="product_id"]', + 'input[type="radio"][name="product_id"]:checked' + ]; + + var productId = parseInt($modal.find(productSelector.join(', ')).first().val(), 10); + var productReady = this.renderer.selectOrCreateProduct( + $modal, + productId, + $modal.find('.product_template_id').val(), + false + ); + + productReady.done(function (productId){ + $modal.find(productSelector.join(', ')).val(productId); + + var variantValues = self + .renderer + .getSelectedVariantValues($modal.find('.js_product')); + + var productCustomVariantValues = self + .renderer + .getCustomVariantValues($modal.find('.js_product')); + + var noVariantAttributeValues = self + .renderer + .getNoVariantAttributeValues($modal.find('.js_product')); + + self.rootProduct = { + product_id: productId, + quantity: parseFloat($modal.find('input[name="add_qty"]').val() || 1), + variant_values: variantValues, + product_custom_attribute_values: productCustomVariantValues, + no_variant_attribute_values: noVariantAttributeValues + }; + + self.optionalProductsModal = new OptionalProductsModal($('body'), { + rootProduct: self.rootProduct, + pricelistId: self.renderer.pricelistId, + okButtonText: _t('Confirm'), + cancelButtonText: _t('Back'), + title: _t('Configure') + }).open(); + + self.optionalProductsModal.on('options_empty', null, + self._onModalOptionsEmpty.bind(self)); + + self.optionalProductsModal.on('update_quantity', null, + self._onOptionsUpdateQuantity.bind(self)); + + self.optionalProductsModal.on('confirm', null, + self._onModalConfirm.bind(self)); + }); + }, + + /** + * No optional products found for this product, only add the root product + * + * @private + */ + _onModalOptionsEmpty: function () { + this._addProducts([this.rootProduct]); + }, + + /** + * Add all selected products + * + * @private + */ + _onModalConfirm: function () { + this._addProducts(this.optionalProductsModal.getSelectedProducts()); + }, + + /** + * Update product configurator form + * when quantity is updated in the optional products window + * + * @private + * @param {integer} quantity + */ + _onOptionsUpdateQuantity: function (quantity) { + this.$el + .find('input[name="add_qty"]') + .val(quantity) + .trigger('change'); + }, + + /** + * This triggers the close action for the window and + * adds the product as the "infos" parameter. + * It will allow the caller (typically the SO line form) of this window + * to handle the added products. + * + * @private + * @param {Array} products the list of added products + * {integer} products.product_id: the id of the product + * {integer} products.quantity: the added quantity for this product + * {Array} products.product_custom_attribute_values: + * see product_configurator_mixin.getCustomVariantValues + * {Array} products.no_variant_attribute_values: + * see product_configurator_mixin.getNoVariantAttributeValues + */ + _addProducts: function (products) { + this.do_action({type: 'ir.actions.act_window_close', infos: products}); + } +}); + +return ProductConfiguratorFormController; + +}); \ No newline at end of file diff --git a/sale_line_reconfigure/static/src/js/product_configurator_renderer.js b/sale_line_reconfigure/static/src/js/product_configurator_renderer.js new file mode 100644 index 00000000..da1135a9 --- /dev/null +++ b/sale_line_reconfigure/static/src/js/product_configurator_renderer.js @@ -0,0 +1,55 @@ +odoo.define('sale_line_reconfigure.ProductConfiguratorFormRenderer', function (require) { +"use strict"; + +var FormRenderer = require('web.FormRenderer'); +var ProductConfiguratorMixin = require('sale.ProductConfiguratorMixin'); + +var ProductConfiguratorFormRenderer = FormRenderer.extend(ProductConfiguratorMixin ,{ + /** + * @override + */ + init: function (){ + this._super.apply(this, arguments); + this.pricelistId = this.state.context.default_pricelist_id || 0; + + // Override + this.saleLineId = this.state.context.default_sale_line_id || 0; + // End Override + }, + /** + * @override + */ + start: function () { + this._super.apply(this, arguments); + this.$el.append($('
', {class: 'configurator_container'})); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Renders the product configurator within the form + * + * Will also: + * - add events handling for variant changes + * - trigger variant change to compute the price and other + * variant specific changes + * + * @param {string} configuratorHtml the evaluated template of + * the product configurator + */ + renderConfigurator: function (configuratorHtml) { + var $configuratorContainer = this.$('.configurator_container'); + $configuratorContainer.empty(); + + var $configuratorHtml = $(configuratorHtml); + $configuratorHtml.appendTo($configuratorContainer); + + this.triggerVariantChange($configuratorContainer); + } +}); + +return ProductConfiguratorFormRenderer; + +}); diff --git a/sale_line_reconfigure/static/src/js/product_configurator_view.js b/sale_line_reconfigure/static/src/js/product_configurator_view.js new file mode 100644 index 00000000..b3a34ea7 --- /dev/null +++ b/sale_line_reconfigure/static/src/js/product_configurator_view.js @@ -0,0 +1,20 @@ +odoo.define('sale_line_reconfigure.ProductConfiguratorFormView', function (require) { +"use strict"; + +var ProductConfiguratorFormController = require('sale_line_reconfigure.ProductConfiguratorFormController'); +var ProductConfiguratorFormRenderer = require('sale_line_reconfigure.ProductConfiguratorFormRenderer'); +var FormView = require('web.FormView'); +var viewRegistry = require('web.view_registry'); + +var ProductConfiguratorFormView = FormView.extend({ + config: _.extend({}, FormView.prototype.config, { + Controller: ProductConfiguratorFormController, + Renderer: ProductConfiguratorFormRenderer, + }), +}); + +viewRegistry.add('product_configurator_form', ProductConfiguratorFormView); + +return ProductConfiguratorFormView; + +}); \ No newline at end of file diff --git a/sale_line_reconfigure/static/src/js/section_and_note_fields_backend.js b/sale_line_reconfigure/static/src/js/section_and_note_fields_backend.js new file mode 100644 index 00000000..539c6368 --- /dev/null +++ b/sale_line_reconfigure/static/src/js/section_and_note_fields_backend.js @@ -0,0 +1,227 @@ +odoo.define('sale_line_reconfigure.section_and_note_backend', function (require) { +// The goal of this file is to contain JS hacks related to allowing +// section and note on sale order and invoice. + +// [UPDATED] now also allows configuring products on sale order. +// This is a copy job from `account` to support re-configuring a selected sale order line. + +"use strict"; +var pyUtils = require('web.py_utils'); +var core = require('web.core'); +var _t = core._t; +var FieldChar = require('web.basic_fields').FieldChar; +var FieldOne2Many = require('web.relational_fields').FieldOne2Many; +var fieldRegistry = require('web.field_registry'); +var FieldText = require('web.basic_fields').FieldText; +var ListRenderer = require('web.ListRenderer'); + +var SectionAndNoteListRenderer = ListRenderer.extend({ + /** + * We want section and note to take the whole line (except handle and trash) + * to look better and to hide the unnecessary fields. + * + * @override + */ + _renderBodyCell: function (record, node, index, options) { + var $cell = this._super.apply(this, arguments); + + var isSection = record.data.display_type === 'line_section'; + var isNote = record.data.display_type === 'line_note'; + + if (isSection || isNote) { + if (node.attrs.widget === "handle") { + return $cell; + } else if (node.attrs.name === "name") { + var nbrColumns = this._getNumberOfCols(); + if (this.handleField) { + nbrColumns--; + } + if (this.addTrashIcon) { + nbrColumns--; + } + $cell.attr('colspan', nbrColumns); + } else { + return $cell.addClass('o_hidden'); + } + } + + return $cell; + }, + /** + * We add the o_is_{display_type} class to allow custom behaviour both in JS and CSS. + * + * @override + */ + _renderRow: function (record, index) { + var $row = this._super.apply(this, arguments); + + if (record.data.display_type) { + $row.addClass('o_is_' + record.data.display_type); + } + + return $row; + }, + /** + * We want to add .o_section_and_note_list_view on the table to have stronger CSS. + * + * @override + * @private + */ + _renderView: function () { + var def = this._super(); + this.$el.find('> table').addClass('o_section_and_note_list_view'); + return def; + }, + /** + * Add support for product configurator + * + * @override + * @private + */ + _onAddRecord: function (ev) { + // we don't want the browser to navigate to a the # url + ev.preventDefault(); + + // we don't want the click to cause other effects, such as unselecting + // the row that we are creating, because it counts as a click on a tr + ev.stopPropagation(); + + var lineId = null; + if (this.currentRow !== null && this.state.data[this.currentRow].res_id) { + lineId = this.state.data[this.currentRow].res_id; + } + + // but we do want to unselect current row + var self = this; + this.unselectRow().then(function () { + var context = ev.currentTarget.dataset.context; + + var pricelistId = self._getPricelistId(); + if (context && pyUtils.py_eval(context).open_product_configurator){ + self._rpc({ + model: 'ir.model.data', + method: 'xmlid_to_res_id', + kwargs: {xmlid: 'sale.sale_product_configurator_view_form'}, + }).then(function (res_id) { + self.do_action({ + name: _t('Configure a product'), + type: 'ir.actions.act_window', + res_model: 'sale.product.configurator', + views: [[res_id, 'form']], + target: 'new', + context: { + 'default_pricelist_id': pricelistId, + 'default_sale_line_id': lineId, + } + }, { + on_close: function (products) { + if (products && products !== 'special'){ + self.trigger_up('add_record', { + context: self._productsToRecords(products), + forceEditable: "bottom" , + allowWarning: true, + onSuccess: function (){ + self.unselectRow(); + } + }); + } + } + }); + }); + } else { + self.trigger_up('add_record', {context: context && [context]}); // TODO write a test, the deferred was not considered + } + }); + }, + + /** + * Will try to get the pricelist_id value from the parent sale_order form + * + * @private + * @returns {integer} pricelist_id's id + */ + _getPricelistId: function () { + var saleOrderForm = this.getParent() && this.getParent().getParent(); + var stateData = saleOrderForm && saleOrderForm.state && saleOrderForm.state.data; + var pricelist_id = stateData.pricelist_id && stateData.pricelist_id.data && stateData.pricelist_id.data.id; + + return pricelist_id; + }, + + /** + * Will map the products to appropriate record objects that are + * ready for the default_get + * + * @private + * @param {Array} products The products to transform into records + */ + _productsToRecords: function (products) { + var records = []; + _.each(products, function (product){ + var record = { + default_product_id: product.product_id, + default_product_uom_qty: product.quantity + }; + + if (product.no_variant_attribute_values) { + var default_product_no_variant_attribute_values = []; + _.each(product.no_variant_attribute_values, function (attribute_value) { + default_product_no_variant_attribute_values.push( + [4, parseInt(attribute_value.value)] + ); + }); + record['default_product_no_variant_attribute_value_ids'] + = default_product_no_variant_attribute_values; + } + + if (product.product_custom_attribute_values) { + var default_custom_attribute_values = []; + _.each(product.product_custom_attribute_values, function (attribute_value) { + default_custom_attribute_values.push( + [0, 0, { + attribute_value_id: attribute_value.attribute_value_id, + custom_value: attribute_value.custom_value + }] + ); + }); + record['default_product_custom_attribute_value_ids'] + = default_custom_attribute_values; + } + + records.push(record); + }); + + return records; + } +}); + +// We create a custom widget because this is the cleanest way to do it: +// to be sure this custom code will only impact selected fields having the widget +// and not applied to any other existing ListRenderer. +var SectionAndNoteFieldOne2Many = FieldOne2Many.extend({ + /** + * We want to use our custom renderer for the list. + * + * @override + */ + _getRenderer: function () { + if (this.view.arch.tag === 'tree') { + return SectionAndNoteListRenderer; + } + return this._super.apply(this, arguments); + }, +}); + +// This is a merge between a FieldText and a FieldChar. +// We want a FieldChar for section, +// and a FieldText for the rest (product and note). +var SectionAndNoteFieldText = function (parent, name, record, options) { + var isSection = record.data.display_type === 'line_section'; + var Constructor = isSection ? FieldChar : FieldText; + return new Constructor(parent, name, record, options); +}; + +fieldRegistry.add('section_and_note_one2many', SectionAndNoteFieldOne2Many); +fieldRegistry.add('section_and_note_text', SectionAndNoteFieldText); + +}); diff --git a/sale_line_reconfigure/views/assets.xml b/sale_line_reconfigure/views/assets.xml new file mode 100644 index 00000000..dc682f44 --- /dev/null +++ b/sale_line_reconfigure/views/assets.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/sale_line_reconfigure/views/product_views.xml b/sale_line_reconfigure/views/product_views.xml new file mode 100644 index 00000000..ccf2c11d --- /dev/null +++ b/sale_line_reconfigure/views/product_views.xml @@ -0,0 +1,24 @@ + + + + product.template.attribute.value.view.tree.inherit + product.template.attribute.value + + + + + + + + + + product.template.attribute.value.view.form.inherit + product.template.attribute.value + + + + + + + + \ No newline at end of file diff --git a/sale_line_reconfigure/views/sale_product_configurator_templates.xml b/sale_line_reconfigure/views/sale_product_configurator_templates.xml new file mode 100644 index 00000000..184610aa --- /dev/null +++ b/sale_line_reconfigure/views/sale_product_configurator_templates.xml @@ -0,0 +1,27 @@ + + + + + + \ No newline at end of file