Initial commit of sale_line_reconfigure for 12.0

This commit is contained in:
Jared Kipe
2019-02-13 19:02:46 -08:00
parent 319a55431d
commit 432944cfaf
13 changed files with 674 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
from . import controllers
from . import models

View File

@@ -0,0 +1,29 @@
{
'name': 'Sale Line Reconfigure',
'author': 'Hibou Corp. <hello@hibou.io>',
'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',
],
}

View File

@@ -0,0 +1 @@
from . import product_configurator

View File

@@ -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)

View File

@@ -0,0 +1 @@
from . import product

View File

@@ -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)

View File

@@ -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;
});

View File

@@ -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($('<div>', {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;
});

View File

@@ -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;
});

View File

@@ -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);
});

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<template id="assets_backend" name="sale line reconfigure assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<script type="text/javascript" src="/sale_line_reconfigure/static/src/js/product_configurator_controller.js"></script>
<script type="text/javascript" src="/sale_line_reconfigure/static/src/js/product_configurator_renderer.js"></script>
<script type="text/javascript" src="/sale_line_reconfigure/static/src/js/product_configurator_view.js"></script>
<script type="text/javascript" src="/sale_line_reconfigure/static/src/js/section_and_note_fields_backend.js"></script>
</xpath>
</template>
</odoo>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="product_template_attribute_value_view_tree_inherit" model="ir.ui.view">
<field name="name">product.template.attribute.value.view.tree.inherit</field>
<field name="model">product.template.attribute.value</field>
<field name="inherit_id" ref="product.product_template_attribute_value_view_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='name']" position="after">
<field name="is_default"/>
</xpath>
</field>
</record>
<record id="product_template_attribute_value_view_form_inherit" model="ir.ui.view">
<field name="name">product.template.attribute.value.view.form.inherit</field>
<field name="model">product.template.attribute.value</field>
<field name="inherit_id" ref="product.product_template_attribute_value_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='name']" position="after">
<field name="is_default"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<template id="product_configurator_configure_inherit" name="Configure" inherit_id="sale.product_configurator_configure">
<xpath expr="//input[@t-attf-name='product_id']" position="after">
<input type="hidden" class="start_product_id" t-attf-name="start_product_id" t-att-value="start_product_variant_id"/>
</xpath>
</template>
<template id="variants_inherit" inherit_id="sale.variants">
<xpath expr="//t[@t-set='attribute_exclusions']" position="after">
<t t-set="attribute_value_defaults" t-value="get_attribute_value_defaults(product, sale_line)"/>
</xpath>
<xpath expr="//t[@t-foreach='variant_id.product_template_value_ids']/option" position="attributes">
<attribute name="t-att-data-is_custom">value_id.is_custom</attribute>
<attribute name="t-att-selected">'selected' if value_id.product_attribute_value_id in attribute_value_defaults else None</attribute>
</xpath>
<xpath expr="//t[@t-foreach='variant_id.product_template_value_ids']/li/label/div/input" position="attributes">
<attribute name="t-att-checked">'checked' if value_id.product_attribute_value_id in attribute_value_defaults else None</attribute>
</xpath>
<xpath expr="//li[@t-foreach='variant_id.product_template_value_ids']/label" position="attributes">
<attribute name="t-attf-class">css_attribute_color #{'active' if value_id.product_attribute_value_id in attribute_value_defaults else ''} #{'custom_value' if value_id.is_custom else ''}</attribute>
</xpath>
<xpath expr="//li[@t-foreach='variant_id.product_template_value_ids']/label/input" position="attributes">
<attribute name="t-att-checked">'checked' if value_id.product_attribute_value_id in attribute_value_defaults else None</attribute>
</xpath>
</template>
</odoo>