summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--addons/point_of_sale/__manifest__.py1
-rw-r--r--addons/point_of_sale/data/point_of_sale_demo.xml63
-rw-r--r--addons/point_of_sale/models/pos_config.py1
-rw-r--r--addons/point_of_sale/models/pos_order.py1
-rw-r--r--addons/point_of_sale/security/ir.model.access.csv6
-rw-r--r--addons/point_of_sale/static/src/css/pos.css126
-rw-r--r--addons/point_of_sale/static/src/js/Popups/ProductConfiguratorPopup.js89
-rw-r--r--addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderlineDetails.js2
-rw-r--r--addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js21
-rw-r--r--addons/point_of_sale/static/src/js/models.js92
-rw-r--r--addons/point_of_sale/static/src/xml/Chrome.xml2
-rw-r--r--addons/point_of_sale/static/src/xml/Popups/ProductConfiguratorPopup.xml90
-rw-r--r--addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml2
-rw-r--r--addons/point_of_sale/static/tests/tours/ProductConfigurator.tour.js66
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/ProductConfiguratorTourMethods.js77
-rw-r--r--addons/point_of_sale/tests/test_frontend.py73
-rw-r--r--addons/point_of_sale/views/pos_assets_common.xml1
-rw-r--r--addons/point_of_sale/views/pos_assets_index.xml2
-rw-r--r--addons/point_of_sale/views/pos_config_view.xml11
-rw-r--r--addons/point_of_sale/views/pos_order_view.xml4
-rw-r--r--addons/product/models/product_attribute.py32
-rw-r--r--addons/product/security/ir.model.access.csv2
-rw-r--r--addons/product/views/product_attribute_views.xml10
-rw-r--r--addons/sale/__manifest__.py1
-rw-r--r--addons/sale/models/product_product.py42
-rw-r--r--addons/sale/security/ir.model.access.csv2
-rw-r--r--addons/sale/views/product_attribute_views.xml68
27 files changed, 762 insertions, 125 deletions
diff --git a/addons/point_of_sale/__manifest__.py b/addons/point_of_sale/__manifest__.py
index 02211672a69..b66811bc141 100644
--- a/addons/point_of_sale/__manifest__.py
+++ b/addons/point_of_sale/__manifest__.py
@@ -111,6 +111,7 @@
'static/src/xml/Popups/NumberPopup.xml',
'static/src/xml/Popups/OfflineErrorPopup.xml',
'static/src/xml/Popups/OrderImportPopup.xml',
+ 'static/src/xml/Popups/ProductConfiguratorPopup.xml',
'static/src/xml/Screens/ProductScreen/ControlButtons/SetPricelistButton.xml',
'static/src/xml/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.xml',
'static/src/xml/ChromeWidgets/ClientScreenButton.xml',
diff --git a/addons/point_of_sale/data/point_of_sale_demo.xml b/addons/point_of_sale/data/point_of_sale_demo.xml
index 7ad7273ac2c..c5a5dee0949 100644
--- a/addons/point_of_sale/data/point_of_sale_demo.xml
+++ b/addons/point_of_sale/data/point_of_sale_demo.xml
@@ -103,6 +103,69 @@
<field name="taxes_id" eval='[(5,)]'/> <!-- no taxes -->
</record>
+ <function model="ir.model.data" name="_update_xmlids">
+ <value model="base" eval="[{
+ 'xml_id': 'point_of_sale.desk_organizer_product_template',
+ 'record': obj().env.ref('point_of_sale.desk_organizer').product_tmpl_id,
+ 'noupdate': True,
+ }]"/>
+ </function>
+
+ <record id="size_attribute" model="product.attribute">
+ <field name="name">Size</field>
+ <field name="sequence">30</field>
+ <field name="display_type">radio</field>
+ <field name="create_variant">no_variant</field>
+ </record>
+ <record id="size_attribute_s" model="product.attribute.value">
+ <field name="name">S</field>
+ <field name="sequence">1</field>
+ <field name="attribute_id" ref="size_attribute"/>
+ </record>
+ <record id="size_attribute_m" model="product.attribute.value">
+ <field name="name">M</field>
+ <field name="sequence">2</field>
+ <field name="attribute_id" ref="size_attribute"/>
+ </record>
+ <record id="size_attribute_l" model="product.attribute.value">
+ <field name="name">L</field>
+ <field name="sequence">3</field>
+ <field name="attribute_id" ref="size_attribute"/>
+ </record>
+ <record id="desk_organizer_size" model="product.template.attribute.line">
+ <field name="product_tmpl_id" ref="point_of_sale.desk_organizer_product_template"/>
+ <field name="attribute_id" ref="size_attribute"/>
+ <field name="value_ids" eval="[(6, 0, [ref('size_attribute_s'), ref('size_attribute_m'), ref('size_attribute_l')])]"/>
+ </record>
+
+ <record id="fabric_attribute" model="product.attribute">
+ <field name="name">Fabric</field>
+ <field name="sequence">40</field>
+ <field name="display_type">select</field>
+ <field name="create_variant">no_variant</field>
+ </record>
+ <record id="fabric_attribute_plastic" model="product.attribute.value">
+ <field name="name">Plastic</field>
+ <field name="sequence">1</field>
+ <field name="attribute_id" ref="fabric_attribute"/>
+ </record>
+ <record id="fabric_attribute_leather" model="product.attribute.value">
+ <field name="name">Leather</field>
+ <field name="sequence">2</field>
+ <field name="attribute_id" ref="fabric_attribute"/>
+ </record>
+ <record id="fabric_attribute_custom" model="product.attribute.value">
+ <field name="name">Custom</field>
+ <field name="sequence">3</field>
+ <field name="attribute_id" ref="fabric_attribute"/>
+ <field name="is_custom">True</field>
+ </record>
+ <record id="desk_organizer_fabric" model="product.template.attribute.line">
+ <field name="product_tmpl_id" ref="point_of_sale.desk_organizer_product_template"/>
+ <field name="attribute_id" ref="fabric_attribute"/>
+ <field name="value_ids" eval="[(6, 0, [ref('fabric_attribute_plastic'), ref('fabric_attribute_leather'), ref('fabric_attribute_custom')])]"/>
+ </record>
+
<record id="magnetic_board" model="product.product">
<field name="available_in_pos">True</field>
<field name="list_price">1.98</field>
diff --git a/addons/point_of_sale/models/pos_config.py b/addons/point_of_sale/models/pos_config.py
index 942a4e573ed..6759532f408 100644
--- a/addons/point_of_sale/models/pos_config.py
+++ b/addons/point_of_sale/models/pos_config.py
@@ -202,6 +202,7 @@ class PosConfig(models.Model):
module_pos_loyalty = fields.Boolean("Loyalty Program")
module_pos_mercury = fields.Boolean(string="Integrated Card Payments")
manage_orders = fields.Boolean(string="Manage Orders")
+ product_configurator = fields.Boolean(string="Product Configurator")
is_posbox = fields.Boolean("PosBox")
is_header_or_footer = fields.Boolean("Header & Footer")
module_pos_hr = fields.Boolean(help="Show employee login screen")
diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py
index ada032ee643..b87657212c8 100644
--- a/addons/point_of_sale/models/pos_order.py
+++ b/addons/point_of_sale/models/pos_order.py
@@ -664,6 +664,7 @@ class PosOrderLine(models.Model):
pack_lot_ids = fields.One2many('pos.pack.operation.lot', 'pos_order_line_id', string='Lot/serial Number')
product_uom_id = fields.Many2one('uom.uom', string='Product UoM', related='product_id.uom_id')
currency_id = fields.Many2one('res.currency', related='order_id.currency_id')
+ full_product_name = fields.Char('Full Product Name')
@api.model
def _prepare_refund_data(self, refund_order_id):
diff --git a/addons/point_of_sale/security/ir.model.access.csv b/addons/point_of_sale/security/ir.model.access.csv
index f4415cc8321..35f4d181775 100644
--- a/addons/point_of_sale/security/ir.model.access.csv
+++ b/addons/point_of_sale/security/ir.model.access.csv
@@ -16,6 +16,12 @@ access_product_product,product.product,product.model_product_product,group_pos_u
access_product_template_pos_user,product.template pos user,product.model_product_template,group_pos_user,1,0,0,0
access_product_product_pos_manager,product.product.pos manager,product.model_product_product,group_pos_manager,1,1,1,1
access_product_template_pos_manager,product.template pos manager,product.model_product_template,group_pos_manager,1,1,1,1
+access_product_attribute_pos_manager,product.attribute,product.model_product_attribute,group_pos_manager,1,1,1,1
+access_product_attribute_value_pos_manager,product.attribute value,product.model_product_attribute_value,group_pos_manager,1,1,1,1
+access_product_attribute_custom_value_pos_manager,product.attribute.custom value manager,product.model_product_attribute_custom_value,group_pos_manager,1,1,1,1
+access_product_product_attribute_pos_manager,product.template.attribute value,product.model_product_template_attribute_value,group_pos_manager,1,1,1,1
+access_product_template_attribute_exclusion_pos_manager,product.template.attribute exclusion,product.model_product_template_attribute_exclusion,group_pos_manager,1,1,1,1
+access_product_template_attribute_line_pos_manager,product.template.attribute line,product.model_product_template_attribute_line,group_pos_manager,1,1,1,1
access_account_bank_statement_line_manager,account.bank.statement.line manager,account.model_account_bank_statement_line,group_pos_manager,1,1,1,1
access_product_product_supplierinfo_user,product.supplierinfo user,product.model_product_supplierinfo,group_pos_user,1,0,0,0
access_product_product_packaging_user,product.packaging user,product.model_product_packaging,group_pos_user,1,0,0,0
diff --git a/addons/point_of_sale/static/src/css/pos.css b/addons/point_of_sale/static/src/css/pos.css
index b3b62acc27b..39ee679e8ca 100644
--- a/addons/point_of_sale/static/src/css/pos.css
+++ b/addons/point_of_sale/static/src/css/pos.css
@@ -2504,6 +2504,13 @@ td {
left: 50%;
transform: translate(-50%, -50%);
}
+
+.pos .modal-dialog .popup-lg{
+ max-width: 80%;
+ max-height: 600px;
+ height: auto;
+}
+
.pos .modal-dialog .popup-confirm{
height: 250px;
}
@@ -2524,6 +2531,12 @@ td {
margin: 16px;
margin-top: 20px;
}
+
+.pos .popup-lg .body {
+ max-height: 400px;
+ overflow-y: auto;
+}
+
.pos .popup .body.traceback {
height: 238px;
overflow: auto;
@@ -2536,9 +2549,6 @@ td {
user-select: text;
}
.pos .popup .footer{
- position:absolute;
- bottom:0;
- left:0;
width:100%;
height:60px;
border-top: solid 1px rgba(60,60,60,0.1);
@@ -3024,3 +3034,113 @@ td {
.order-receipt .title {
font-size: large;
}
+
+/* ********* Product Configurator Popup ********* */
+
+.pos .product_configurator_attributes {
+ text-align: left;
+ margin: 1em;
+}
+
+.pos .product_configurator_attributes .attribute {
+ margin-bottom: 1em;
+ display: inline-block;
+ width: 45%;
+ padding-left: 0.5em;
+ vertical-align: top;
+}
+
+@media screen and (max-width: 768px) {
+ .pos .product_configurator_attributes .attribute {
+ width: 95%;
+ }
+}
+
+.pos .product_configurator_attributes .attribute_name {
+ margin-bottom: 0.5em;
+ font-weight: bold;
+}
+
+.pos .product_configurator_attributes input {
+ min-height: 0;
+ width: auto;
+}
+
+/** Radio attribute **/
+
+.pos .product_configurator_attributes .configurator_radio {
+ line-height: 1.5;
+}
+
+.pos .product_configurator_attributes .configurator_radio input[type='radio'] {
+ box-shadow: none;
+ margin-right: 0.5em;
+}
+
+.pos .product_configurator_attributes .configurator_radio .radio_attribute_label {
+ font-weight: normal;
+ display: inline-block;
+ width: 80%;
+}
+
+.pos .product_configurator_attributes .configurator_radio .price_extra {
+ margin-left: 0.5em;
+ padding: 0.2em 0.4em;
+ border-radius: 10rem;
+ color: #FFFFFF;
+ background-color: #6c757d;
+}
+
+.pos .product_configurator_attributes .configurator_radio .custom_value {
+ margin: 0.3em 1.3em;
+}
+
+/** Selector attribute **/
+
+.pos .product_configurator_attributes .configurator_select {
+ cursor: pointer;
+ background-color: transparent;
+ width: 90%;
+ padding: 0.5em;
+ color: #666666;
+ font-size: 18px;
+ margin-bottom: 0.5em;
+}
+
+/** Color attribute **/
+
+.pos .product_configurator_attributes ul.color_attribute_list {
+ margin-left: 0;
+}
+
+.pos .product_configurator_attributes li.color_attribute_list_item:before {
+ content: '';
+}
+
+.pos .product_configurator_attributes li.color_attribute_list_item {
+ margin-bottom: 0.5em;
+ text-indent: 0;
+ display: inline-block;
+}
+
+.pos .product_configurator_attributes .color_attribute_list_item:not(:last-child) {
+ margin-right: 1rem;
+}
+
+.pos .product_configurator_attributes .configurator_color {
+ display: inline-block;
+ border: 1px solid #999999;
+}
+
+.pos .product_configurator_attributes .configurator_color.active {
+ border: 3px ridge #66ee66;
+}
+
+.pos .product_configurator_attributes .configurator_color input {
+ margin: 20px;
+ opacity: 0;
+}
+
+.pos .product_configurator_attributes .configurator_color.active input {
+ margin: 18px;
+}
diff --git a/addons/point_of_sale/static/src/js/Popups/ProductConfiguratorPopup.js b/addons/point_of_sale/static/src/js/Popups/ProductConfiguratorPopup.js
new file mode 100644
index 00000000000..b04e55d8929
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Popups/ProductConfiguratorPopup.js
@@ -0,0 +1,89 @@
+odoo.define('point_of_sale.ProductConfiguratorPopup', function(require) {
+ 'use strict';
+
+ const { useState, useSubEnv } = owl.hooks;
+ const PosComponent = require('point_of_sale.PosComponent');
+ const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
+ const Registries = require('point_of_sale.Registries');
+
+ class ProductConfiguratorPopup extends AbstractAwaitablePopup {
+ constructor() {
+ super(...arguments);
+ useSubEnv({ attribute_components: [] });
+ }
+
+ getPayload() {
+ var selected_attributes = [];
+ var price_extra = 0.0;
+
+ this.env.attribute_components.forEach((attribute_component) => {
+ let { value, extra } = attribute_component.getValue();
+ selected_attributes.push(value);
+ price_extra += extra;
+ });
+
+ return {
+ selected_attributes,
+ price_extra,
+ };
+ }
+ }
+ ProductConfiguratorPopup.template = 'ProductConfiguratorPopup';
+ Registries.Component.add(ProductConfiguratorPopup);
+
+ class BaseProductAttribute extends PosComponent {
+ constructor() {
+ super(...arguments);
+
+ this.env.attribute_components.push(this);
+
+ this.attribute = this.props.attribute;
+ this.values = this.attribute.values;
+ this.state = useState({
+ selected_value: parseFloat(this.values[0].id),
+ custom_value: '',
+ });
+ }
+
+ getValue() {
+ let selected_value = this.values.find((val) => val.id === parseFloat(this.state.selected_value));
+ let value = selected_value.name;
+ if (selected_value.is_custom && this.state.custom_value) {
+ value += `: ${this.state.custom_value}`;
+ }
+
+ return {
+ value,
+ extra: selected_value.price_extra
+ };
+ }
+ }
+
+ class RadioProductAttribute extends BaseProductAttribute {
+ mounted() {
+ // With radio buttons `t-model` selects the default input by searching for inputs with
+ // a matching `value` attribute. In our case, we use `t-att-value` so `value` is
+ // not found yet and no radio is selected by default.
+ // We then manually select the first input of each radio attribute.
+ $(this.el).find('input[type="radio"]:first').prop('checked', true);
+ }
+ }
+ RadioProductAttribute.template = 'RadioProductAttribute';
+ Registries.Component.add(RadioProductAttribute);
+
+ class SelectProductAttribute extends BaseProductAttribute { }
+ SelectProductAttribute.template = 'SelectProductAttribute';
+ Registries.Component.add(SelectProductAttribute);
+
+ class ColorProductAttribute extends BaseProductAttribute {}
+ ColorProductAttribute.template = 'ColorProductAttribute';
+ Registries.Component.add(ColorProductAttribute);
+
+ return {
+ ProductConfiguratorPopup,
+ BaseProductAttribute,
+ RadioProductAttribute,
+ SelectProductAttribute,
+ ColorProductAttribute,
+ };
+});
diff --git a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderlineDetails.js b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderlineDetails.js
index 78a023a2564..35f6ec5df22 100644
--- a/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderlineDetails.js
+++ b/addons/point_of_sale/static/src/js/Screens/OrderManagementScreen/OrderlineDetails.js
@@ -21,7 +21,7 @@ odoo.define('point_of_sale.OrderlineDetails', function (require) {
return format.float(roundedQuantity, { digits: [69, decimals] });
};
return {
- productName: line.get_product().display_name,
+ productName: line.get_full_product_name(),
totalPrice: line.get_price_with_tax(),
quantity: formatQty(line),
unit: line.get_unit().name,
diff --git a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js
index 69741a8a090..2f966c48c3d 100644
--- a/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js
+++ b/addons/point_of_sale/static/src/js/Screens/ProductScreen/ProductScreen.js
@@ -59,7 +59,24 @@ odoo.define('point_of_sale.ProductScreen', function(require) {
this.env.pos.add_new_order();
}
const product = event.detail;
- let draftPackLotLines, weight, packLotLinesToEdit;
+ let price_extra = 0.0;
+ let draftPackLotLines, weight, description, packLotLinesToEdit;
+
+ if (this.env.pos.config.product_configurator && _.some(product.attribute_line_ids, (id) => id in this.env.pos.attributes_by_ptal_id)) {
+ let attributes = _.map(product.attribute_line_ids, (id) => this.env.pos.attributes_by_ptal_id[id])
+ .filter((attr) => attr !== undefined);
+ let { confirmed, payload } = await this.showPopup('ProductConfiguratorPopup', {
+ product: product,
+ attributes: attributes,
+ });
+
+ if (confirmed) {
+ description = payload.selected_attributes.join(', ');
+ price_extra += payload.price_extra;
+ } else {
+ return;
+ }
+ }
// Gather lot information if required.
if (['serial', 'lot'].includes(product.tracking)) {
@@ -119,6 +136,8 @@ odoo.define('point_of_sale.ProductScreen', function(require) {
// Add the product after having the extra information.
this.currentOrder.add_product(product, {
draftPackLotLines,
+ description: description,
+ price_extra: price_extra,
quantity: weight,
});
diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js
index ba818aab258..44e9527ba9c 100644
--- a/addons/point_of_sale/static/src/js/models.js
+++ b/addons/point_of_sale/static/src/js/models.js
@@ -401,7 +401,7 @@ exports.PosModel = Backbone.Model.extend({
model: 'product.product',
fields: ['display_name', 'lst_price', 'standard_price', 'categ_id', 'pos_categ_id', 'taxes_id',
'barcode', 'default_code', 'to_weight', 'uom_id', 'description_sale', 'description',
- 'product_tmpl_id','tracking', 'write_date', 'available_in_pos'],
+ 'product_tmpl_id','tracking', 'write_date', 'available_in_pos', 'attribute_line_ids'],
order: _.map(['sequence','default_code','name'], function (name) { return {name: name}; }),
domain: function(self){
var domain = ['&', '&', ['sale_ok','=',true],['available_in_pos','=',true],'|',['company_id','=',self.config.company_id[0]],['company_id','=',false]];
@@ -429,6 +429,53 @@ exports.PosModel = Backbone.Model.extend({
}));
},
},{
+ model: 'product.attribute',
+ fields: ['name', 'display_type'],
+ condition: function (self) { return self.config.product_configurator; },
+ domain: function(){ return [['create_variant', '=', 'no_variant']]; },
+ loaded: function(self, product_attributes, tmp) {
+ tmp.product_attributes_by_id = {};
+ _.map(product_attributes, function (product_attribute) {
+ tmp.product_attributes_by_id[product_attribute.id] = product_attribute;
+ });
+ }
+ },{
+ model: 'product.attribute.value',
+ fields: ['name', 'attribute_id', 'is_custom', 'html_color'],
+ condition: function (self) { return self.config.product_configurator; },
+ domain: function(self, tmp){ return [['attribute_id', 'in', _.keys(tmp.product_attributes_by_id).map(parseFloat)]]; },
+ loaded: function(self, pavs, tmp) {
+ tmp.pav_by_id = {};
+ _.map(pavs, function (pav) {
+ tmp.pav_by_id[pav.id] = pav;
+ });
+ }
+ }, {
+ model: 'product.template.attribute.value',
+ fields: ['product_attribute_value_id', 'attribute_id', 'attribute_line_id', 'price_extra'],
+ condition: function (self) { return self.config.product_configurator; },
+ domain: function(self, tmp){ return [['attribute_id', 'in', _.keys(tmp.product_attributes_by_id).map(parseFloat)]]; },
+ loaded: function(self, ptavs, tmp) {
+ self.attributes_by_ptal_id = {};
+ _.map(ptavs, function (ptav) {
+ if (!self.attributes_by_ptal_id[ptav.attribute_line_id[0]]){
+ self.attributes_by_ptal_id[ptav.attribute_line_id[0]] = {
+ id: ptav.attribute_line_id[0],
+ name: tmp.product_attributes_by_id[ptav.attribute_id[0]].name,
+ display_type: tmp.product_attributes_by_id[ptav.attribute_id[0]].display_type,
+ values: [],
+ };
+ }
+ self.attributes_by_ptal_id[ptav.attribute_line_id[0]].values.push({
+ id: ptav.product_attribute_value_id[0],
+ name: tmp.pav_by_id[ptav.product_attribute_value_id[0]].name,
+ is_custom: tmp.pav_by_id[ptav.product_attribute_value_id[0]].is_custom,
+ html_color: tmp.pav_by_id[ptav.product_attribute_value_id[0]].html_color,
+ price_extra: ptav.price_extra,
+ });
+ });
+ }
+ },{
model: 'account.cash.rounding',
fields: ['name', 'rounding', 'rounding_method'],
domain: function(self){return [['id', '=', self.config.rounding_method[0]]]; },
@@ -1562,6 +1609,8 @@ exports.Orderline = Backbone.Model.extend({
this.discount = 0;
this.discountStr = '0';
this.selected = false;
+ this.description = '';
+ this.price_extra = 0;
this.id = orderline_id++;
this.price_manually_set = false;
@@ -1577,6 +1626,8 @@ exports.Orderline = Backbone.Model.extend({
this.price = json.price_unit;
this.set_discount(json.discount);
this.set_quantity(json.qty, 'do not recompute unit price');
+ this.set_description(json.description);
+ this.set_price_extra(json.price_extra);
this.id = json.id ? json.id : orderline_id++;
orderline_id = Math.max(this.id+1,orderline_id);
var pack_lot_lines = json.pack_lot_ids;
@@ -1671,6 +1722,15 @@ exports.Orderline = Backbone.Model.extend({
get_discount_str: function(){
return this.discountStr;
},
+ set_description: function(description){
+ this.description = description || '';
+ },
+ set_price_extra: function(price_extra){
+ this.price_extra = parseFloat(price_extra) || 0.0;
+ },
+ get_price_extra: function () {
+ return this.price_extra;
+ },
// sets the quantity of the product. The quantity will be rounded according to the
// product's unity of measure properties. Quantities greater than zero will not get
// rounded to zero
@@ -1700,7 +1760,7 @@ exports.Orderline = Backbone.Model.extend({
// just like in sale.order changing the quantity will recompute the unit price
if(! keep_price && ! this.price_manually_set){
- this.set_unit_price(this.product.get_price(this.order.pricelist, this.get_quantity()));
+ this.set_unit_price(this.product.get_price(this.order.pricelist, this.get_quantity()) + this.get_price_extra());
this.order.fix_tax_included_price(this);
}
this.trigger('change', this);
@@ -1747,6 +1807,13 @@ exports.Orderline = Backbone.Model.extend({
get_product: function(){
return this.product;
},
+ get_full_product_name: function () {
+ var full_name = this.product.display_name;
+ if (this.description) {
+ full_name += ` (${this.description})`;
+ }
+ return full_name;
+ },
// selects or deselects this orderline
set_selected: function(selected){
this.selected = selected;
@@ -1768,11 +1835,13 @@ exports.Orderline = Backbone.Model.extend({
return false;
}else if(this.get_discount() > 0){ // we don't merge discounted orderlines
return false;
- }else if(!utils.float_is_zero(price - orderline.get_product().get_price(orderline.order.pricelist, this.get_quantity()),
+ }else if(!utils.float_is_zero(price - orderline.get_product().get_price(orderline.order.pricelist, this.get_quantity()) - orderline.get_price_extra(),
this.pos.currency.decimals)){
return false;
}else if(this.product.tracking == 'lot') {
return false;
+ }else if (this.description !== orderline.description) {
+ return false;
}else{
return true;
}
@@ -1797,7 +1866,10 @@ exports.Orderline = Backbone.Model.extend({
product_id: this.get_product().id,
tax_ids: [[6, false, _.map(this.get_applicable_taxes(), function(tax){ return tax.id; })]],
id: this.id,
- pack_lot_ids: pack_lot_ids
+ pack_lot_ids: pack_lot_ids,
+ description: this.description,
+ full_product_name: this.get_full_product_name(),
+ price_extra: this.get_price_extra(),
};
},
//used to create a json of the ticket, to be sent to the printer
@@ -1825,7 +1897,7 @@ exports.Orderline = Backbone.Model.extend({
generate_wrapped_product_name: function() {
var MAX_LENGTH = 24; // 40 * line ratio of .6
var wrapped = [];
- var name = this.get_product().display_name;
+ var name = this.get_full_product_name();
var current_line = "";
while (name.length > 0) {
@@ -2820,6 +2892,12 @@ exports.Order = Backbone.Model.extend({
this.fix_tax_included_price(line);
}
+ if (options.price_extra !== undefined){
+ line.price_extra = options.price_extra;
+ line.set_unit_price(line.get_unit_price() + options.price_extra);
+ this.fix_tax_included_price(line);
+ }
+
if(options.lst_price !== undefined){
line.set_lst_price(options.lst_price);
}
@@ -2828,6 +2906,10 @@ exports.Order = Backbone.Model.extend({
line.set_discount(options.discount);
}
+ if (options.description !== undefined){
+ line.description += options.description;
+ }
+
if(options.extras !== undefined){
for (var prop in options.extras) {
line[prop] = options.extras[prop];
diff --git a/addons/point_of_sale/static/src/xml/Chrome.xml b/addons/point_of_sale/static/src/xml/Chrome.xml
index 243641a5592..39b267e9308 100644
--- a/addons/point_of_sale/static/src/xml/Chrome.xml
+++ b/addons/point_of_sale/static/src/xml/Chrome.xml
@@ -93,7 +93,7 @@
<div t-attf-style="background-image:url(#{orderline.product.image_base64})" />
</div>
<div>
- <t t-esc="orderline.product.display_name" />
+ <t t-esc="orderline.get_full_product_name()" />
</div>
<div>
<t t-esc="orderline.get_quantity_str()" />
diff --git a/addons/point_of_sale/static/src/xml/Popups/ProductConfiguratorPopup.xml b/addons/point_of_sale/static/src/xml/Popups/ProductConfiguratorPopup.xml
new file mode 100644
index 00000000000..fb863754ea0
--- /dev/null
+++ b/addons/point_of_sale/static/src/xml/Popups/ProductConfiguratorPopup.xml
@@ -0,0 +1,90 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <t t-name="ProductConfiguratorPopup" owl="1">
+ <div role="dialog" class="modal-dialog">
+ <div class="popup popup-text popup-lg product-configurator-popup">
+ <header class="title">
+ <t t-esc="props.product.display_name" />
+ </header>
+
+ <main class="body product_configurator_attributes col-lg-4 col-md-6 col-sm-12">
+ <div t-foreach="props.attributes" t-as="attribute" class="attribute">
+ <div class="attribute_name" t-esc="attribute.name"/>
+ <RadioProductAttribute t-if="attribute.display_type === 'radio'" attribute="attribute"/>
+ <SelectProductAttribute t-elif="attribute.display_type === 'select'" attribute="attribute"/>
+ <ColorProductAttribute t-elif="attribute.display_type === 'color'" attribute="attribute"/>
+ </div>
+ </main>
+
+ <footer class="footer">
+ <div class="button highlight confirm" t-on-click="confirm">
+ Add
+ </div>
+ <div class="button cancel" t-on-click="cancel">
+ Cancel
+ </div>
+ </footer>
+ </div>
+ </div>
+ </t>
+
+ <t t-name="RadioProductAttribute" owl="1">
+ <div class="configurator_radio">
+ <div t-foreach="values" t-as="value">
+ <input type="radio" t-model="state.selected_value" t-att-name="attribute.id"
+ t-attf-id="{{ attribute.id }}_{{ value.id }}" t-att-value="value.id"/>
+
+ <label t-attf-for="{{ attribute.id }}_{{ value.id }}">
+ <div class="radio_attribute_label">
+ <t t-esc="value.name"/>
+ <span t-if="value.price_extra" class="price_extra">
+ + <t t-esc="env.pos.format_currency(value.price_extra)"/>
+ </span>
+ </div>
+
+ <t t-if="value.id == state.selected_value &amp;&amp; value.is_custom">
+ <input class="custom_value" type="text" t-model="state.custom_value"/>
+ </t>
+ </label>
+ </div>
+ </div>
+ </t>
+
+ <t t-name="SelectProductAttribute" owl="1">
+ <div>
+ <t t-set="is_custom" t-value="false"/>
+
+ <select class="configurator_select" t-model="state.selected_value">
+ <option t-foreach="values" t-as="value" t-att-value="value.id">
+ <t t-set="is_custom" t-value="is_custom || (value.is_custom &amp;&amp; value.id == state.selected_value)"/>
+ <t t-esc="value.name"/>
+ <t t-if="value.price_extra">
+ + <t t-esc="env.pos.format_currency(value.price_extra)"/>
+ </t>
+ </option>
+ </select>
+
+ <input class="custom_value" t-if="is_custom" type="text" t-model="state.custom_value"/>
+ </div>
+ </t>
+
+ <t t-name="ColorProductAttribute" owl="1">
+ <div>
+ <t t-set="is_custom" t-value="false"/>
+
+ <ul class="color_attribute_list">
+ <li t-foreach="values" t-as="value" class="color_attribute_list_item">
+ <t t-set="is_custom" t-value="is_custom || (value.is_custom &amp;&amp; value.id == state.selected_value)"/>
+ <label t-attf-class="configurator_color {{ value.id == state.selected_value ? 'active' : '' }}"
+ t-attf-style="background-color: {{ value.html_color }};" t-att-data-color="value.name">
+ <input type="radio" t-model="state.selected_value" t-att-value="value.id" t-att-name="attribute.id"/>
+ </label>
+ </li>
+ </ul>
+
+ <input class="custom_value" t-if="is_custom" type="text" t-model="state.custom_value"/>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml
index 0036d50b625..fdd78d7b634 100644
--- a/addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml
+++ b/addons/point_of_sale/static/src/xml/Screens/ProductScreen/Orderline.xml
@@ -4,7 +4,7 @@
<t t-name="Orderline" owl="1">
<li t-on-click="selectLine" class="orderline" t-att-class="addedClasses">
<span class="product-name">
- <t t-esc="props.line.get_product().display_name"/>
+ <t t-esc="props.line.get_full_product_name()"/>
<span> </span>
<t t-if="props.line.get_product().tracking!=='none'">
<t t-if="props.line.has_valid_product_lot()">
diff --git a/addons/point_of_sale/static/tests/tours/ProductConfigurator.tour.js b/addons/point_of_sale/static/tests/tours/ProductConfigurator.tour.js
new file mode 100644
index 00000000000..7afedbf6091
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/ProductConfigurator.tour.js
@@ -0,0 +1,66 @@
+odoo.define('point_of_sale.tour.ProductConfigurator', function (require) {
+ 'use strict';
+
+ const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
+ const { ProductConfigurator } = require('point_of_sale.tour.ProductConfiguratorTourMethods');
+ const { getSteps, startSteps } = require('point_of_sale.tour.utils');
+ var Tour = require('web_tour.tour');
+
+ // signal to start generating steps
+ // when finished, steps can be taken from getSteps
+ startSteps();
+
+ // Go by default to home category
+ ProductScreen.do.clickHomeCategory();
+
+ // Click on Configurable Chair product
+ ProductScreen.do.clickDisplayedProduct('Configurable Chair');
+ ProductConfigurator.check.isShown();
+
+ // Cancel configuration, not product should be in order
+ ProductConfigurator.do.cancelAttributes();
+ ProductScreen.check.orderIsEmpty();
+
+ // Click on Configurable Chair product
+ ProductScreen.do.clickDisplayedProduct('Configurable Chair');
+ ProductConfigurator.check.isShown();
+
+ // Pick Color
+ ProductConfigurator.do.pickColor('Red');
+
+ // Pick Radio
+ ProductConfigurator.do.pickSelect('Metal');
+
+ // Pick Select
+ ProductConfigurator.do.pickRadio('Other');
+
+ // Fill in custom attribute
+ ProductConfigurator.do.fillCustomAttribute('Custom Fabric');
+
+ // Confirm configuration
+ ProductConfigurator.do.confirmAttributes();
+
+ // Check that the product has been added to the order with correct attributes and price
+ ProductScreen.check.selectedOrderlineHas('Configurable Chair (Red, Metal, Other: Custom Fabric)', '1.0', '11.0');
+
+ // Orderlines with the same attributes should be merged
+ ProductScreen.do.clickHomeCategory();
+ ProductScreen.do.clickDisplayedProduct('Configurable Chair');
+ ProductConfigurator.do.pickColor('Red');
+ ProductConfigurator.do.pickSelect('Metal');
+ ProductConfigurator.do.pickRadio('Other');
+ ProductConfigurator.do.fillCustomAttribute('Custom Fabric');
+ ProductConfigurator.do.confirmAttributes();
+ ProductScreen.check.selectedOrderlineHas('Configurable Chair (Red, Metal, Other: Custom Fabric)', '2.0', '22.0');
+
+ // Orderlines with different attributes shouldn't be merged
+ ProductScreen.do.clickHomeCategory();
+ ProductScreen.do.clickDisplayedProduct('Configurable Chair');
+ ProductConfigurator.do.pickColor('Blue');
+ ProductConfigurator.do.pickSelect('Metal');
+ ProductConfigurator.do.pickRadio('Leather');
+ ProductConfigurator.do.confirmAttributes();
+ ProductScreen.check.selectedOrderlineHas('Configurable Chair (Blue, Metal, Leather)', '1.0', '10.0');
+
+ Tour.register('ProductConfiguratorTour', { test: true, url: '/pos/web' }, getSteps());
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/ProductConfiguratorTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ProductConfiguratorTourMethods.js
new file mode 100644
index 00000000000..5d10f9fd430
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/ProductConfiguratorTourMethods.js
@@ -0,0 +1,77 @@
+odoo.define('point_of_sale.tour.ProductConfiguratorTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ pickRadio(name) {
+ return [
+ {
+ content: `picking radio attribute with name ${name}`,
+ trigger: `.product-configurator-popup .radio_attribute_label:contains('${name}')`,
+ },
+ ];
+ }
+
+ pickSelect(name) {
+ return [
+ {
+ content: `picking select attribute with name ${name}`,
+ trigger: `.product-configurator-popup .configurator_select:has(option:contains('${name}'))`,
+ run: `text ${name}`,
+ },
+ ];
+ }
+
+ pickColor(name) {
+ return [
+ {
+ content: `picking color attribute with name ${name}`,
+ trigger: `.product-configurator-popup .configurator_color[data-color='${name}']`,
+ },
+ ];
+ }
+
+ fillCustomAttribute(value) {
+ return [
+ {
+ content: `filling custom attribute with value ${value}`,
+ trigger: `.product-configurator-popup .custom_value`,
+ run: `text ${value}`,
+ },
+ ];
+ }
+
+ confirmAttributes() {
+ return [
+ {
+ content: `confirming product configuration`,
+ trigger: `.product-configurator-popup .button.confirm`,
+ },
+ ];
+ }
+
+ cancelAttributes() {
+ return [
+ {
+ content: `canceling product configuration`,
+ trigger: `.product-configurator-popup .button.cancel`,
+ },
+ ];
+ }
+ }
+
+ class Check {
+ isShown() {
+ return [
+ {
+ content: 'product configurator is shown',
+ trigger: '.product-configurator-popup:not(:has(.oe_hidden))',
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ return createTourMethods('ProductConfigurator', Do, Check);
+});
diff --git a/addons/point_of_sale/tests/test_frontend.py b/addons/point_of_sale/tests/test_frontend.py
index 759c23bee1e..73e9142de86 100644
--- a/addons/point_of_sale/tests/test_frontend.py
+++ b/addons/point_of_sale/tests/test_frontend.py
@@ -109,6 +109,12 @@ class TestPointOfSaleHttpCommon(odoo.tests.HttpCase):
'list_price': 5.10,
'taxes_id': False,
})
+ configurable_chair = env['product.product'].create({
+ 'name': 'Configurable Chair',
+ 'available_in_pos': True,
+ 'list_price': 10,
+ 'taxes_id': False,
+ })
attribute = env['product.attribute'].create({
'name': 'add 2',
@@ -124,6 +130,68 @@ class TestPointOfSaleHttpCommon(odoo.tests.HttpCase):
})
line.product_template_value_ids[0].price_extra = 2
+ chair_color_attribute = env['product.attribute'].create({
+ 'name': 'Color',
+ 'display_type': 'color',
+ 'create_variant': 'no_variant',
+ })
+ chair_color_red = env['product.attribute.value'].create({
+ 'name': 'Red',
+ 'attribute_id': chair_color_attribute.id,
+ 'html_color': '#ff0000',
+ })
+ chair_color_blue = env['product.attribute.value'].create({
+ 'name': 'Blue',
+ 'attribute_id': chair_color_attribute.id,
+ 'html_color': '#0000ff',
+ })
+ chair_color_line = env['product.template.attribute.line'].create({
+ 'product_tmpl_id': configurable_chair.product_tmpl_id.id,
+ 'attribute_id': chair_color_attribute.id,
+ 'value_ids': [(6, 0, [chair_color_red.id, chair_color_blue.id])]
+ })
+ chair_color_line.product_template_value_ids[0].price_extra = 1
+
+ chair_legs_attribute = env['product.attribute'].create({
+ 'name': 'Chair Legs',
+ 'display_type': 'select',
+ 'create_variant': 'no_variant',
+ })
+ chair_legs_metal = env['product.attribute.value'].create({
+ 'name': 'Metal',
+ 'attribute_id': chair_legs_attribute.id,
+ })
+ chair_legs_wood = env['product.attribute.value'].create({
+ 'name': 'Wood',
+ 'attribute_id': chair_legs_attribute.id,
+ })
+ chair_legs_line = env['product.template.attribute.line'].create({
+ 'product_tmpl_id': configurable_chair.product_tmpl_id.id,
+ 'attribute_id': chair_legs_attribute.id,
+ 'value_ids': [(6, 0, [chair_legs_metal.id, chair_legs_wood.id])]
+ })
+
+ chair_fabrics_attribute = env['product.attribute'].create({
+ 'name': 'Fabrics',
+ 'display_type': 'radio',
+ 'create_variant': 'no_variant',
+ })
+ chair_fabrics_leather = env['product.attribute.value'].create({
+ 'name': 'Leather',
+ 'attribute_id': chair_fabrics_attribute.id,
+ })
+ chair_fabrics_other = env['product.attribute.value'].create({
+ 'name': 'Other',
+ 'attribute_id': chair_fabrics_attribute.id,
+ 'is_custom': True,
+ })
+ chair_fabrics_line = env['product.template.attribute.line'].create({
+ 'product_tmpl_id': configurable_chair.product_tmpl_id.id,
+ 'attribute_id': chair_fabrics_attribute.id,
+ 'value_ids': [(6, 0, [chair_fabrics_leather.id, chair_fabrics_other.id])]
+ })
+ chair_color_line.product_template_value_ids[1].is_custom = True
+
fixed_pricelist = env['product.pricelist'].create({
'name': 'Fixed',
'item_ids': [(0, 0, {
@@ -437,3 +505,8 @@ class TestUi(TestPointOfSaleHttpCommon):
self.main_pos_config.write({ 'manage_orders': True, 'module_account': True })
self.main_pos_config.open_session_cb(check_coa=False)
self.start_tour("/pos/web?config_id=%d" % self.main_pos_config.id, 'OrderManagementScreenTour', login="admin", step_delay=50)
+
+ def test_04_product_configurator(self):
+ self.main_pos_config.write({ 'product_configurator': True })
+ self.main_pos_config.open_session_cb(check_coa=False)
+ self.start_tour("/pos/web?config_id=%d" % self.main_pos_config, 'ProductConfiguratorTour', login="admin", step_delay=50)
diff --git a/addons/point_of_sale/views/pos_assets_common.xml b/addons/point_of_sale/views/pos_assets_common.xml
index 6979503c517..1c191806a40 100644
--- a/addons/point_of_sale/views/pos_assets_common.xml
+++ b/addons/point_of_sale/views/pos_assets_common.xml
@@ -93,6 +93,7 @@
<script type="text/javascript" src="/point_of_sale/static/src/js/Popups/NumberPopup.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Popups/OfflineErrorPopup.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Popups/OrderImportPopup.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/src/js/Popups/ProductConfiguratorPopup.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetPricelistButton.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/Screens/ProductScreen/ControlButtons/SetFiscalPositionButton.js"></script>
<script type="text/javascript" src="/point_of_sale/static/src/js/ChromeWidgets/ClientScreenButton.js"></script>
diff --git a/addons/point_of_sale/views/pos_assets_index.xml b/addons/point_of_sale/views/pos_assets_index.xml
index 90d35211bd2..65799f3c027 100644
--- a/addons/point_of_sale/views/pos_assets_index.xml
+++ b/addons/point_of_sale/views/pos_assets_index.xml
@@ -7,6 +7,7 @@
<script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/utils.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/ProductConfiguratorTourMethods.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/OrderManagementScreenTourMethods.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/ClientListScreenTourMethods.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js"></script>
@@ -18,6 +19,7 @@
<script type="text/javascript" src="/point_of_sale/static/tests/tours/point_of_sale.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/ProductScreen.tour.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/PaymentScreen.tour.js"></script>
+ <script type="text/javascript" src="/point_of_sale/static/tests/tours/ProductConfigurator.tour.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/OrderManagementScreen.tour.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/ReceiptScreen.tour.js"></script>
<script type="text/javascript" src="/point_of_sale/static/tests/tours/Chrome.tour.js"></script>
diff --git a/addons/point_of_sale/views/pos_config_view.xml b/addons/point_of_sale/views/pos_config_view.xml
index 0400e6e4e91..df1a7ed9423 100644
--- a/addons/point_of_sale/views/pos_config_view.xml
+++ b/addons/point_of_sale/views/pos_config_view.xml
@@ -96,6 +96,17 @@
</div>
</div>
</div>
+ <div class="col-12 col-lg-6 o_setting_box" groups="product.group_product_variant" >
+ <div class="o_setting_left_pane">
+ <field name="product_configurator" />
+ </div>
+ <div class="o_setting_right_pane">
+ <label for="product_configurator" />
+ <div class="text-muted">
+ Select product attributes
+ </div>
+ </div>
+ </div>
<div id="category_reference" class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="iface_display_categ_images"/>
diff --git a/addons/point_of_sale/views/pos_order_view.xml b/addons/point_of_sale/views/pos_order_view.xml
index c128ef3870f..ef7ac896afc 100644
--- a/addons/point_of_sale/views/pos_order_view.xml
+++ b/addons/point_of_sale/views/pos_order_view.xml
@@ -45,7 +45,7 @@
<page string="Products" name="products">
<field name="lines" colspan="4" nolabel="1">
<tree string="Order lines" editable="bottom">
- <field name="product_id"/>
+ <field name="full_product_name"/>
<field name="pack_lot_ids" widget="many2many_tags" groups="stock.group_production_lot"/>
<field name="qty"/>
<field name="product_uom_id" string="UoM" groups="uom.group_uom"/>
@@ -58,7 +58,7 @@
</tree>
<form string="Order lines">
<group col="4">
- <field name="product_id"/>
+ <field name="full_product_name"/>
<field name="qty"/>
<field name="discount" widget="monetary"/>
<field name="price_unit" widget="monetary"/>
diff --git a/addons/product/models/product_attribute.py b/addons/product/models/product_attribute.py
index 33d080f5a02..1f879fbca7b 100644
--- a/addons/product/models/product_attribute.py
+++ b/addons/product/models/product_attribute.py
@@ -30,6 +30,10 @@ class ProductAttribute(models.Model):
required=True)
is_used_on_products = fields.Boolean('Used on Products', compute='_compute_is_used_on_products')
product_tmpl_ids = fields.Many2many('product.template', string="Related Products", compute='_compute_products', store=True)
+ display_type = fields.Selection([
+ ('radio', 'Radio'),
+ ('select', 'Select'),
+ ('color', 'Color')], default='radio', required=True, help="The display type used in the Product Configurator.")
@api.depends('product_tmpl_ids')
def _compute_is_used_on_products(self):
@@ -94,6 +98,12 @@ class ProductAttributeValue(models.Model):
relation='product_attribute_value_product_template_attribute_line_rel', copy=False)
is_used_on_products = fields.Boolean('Used on Products', compute='_compute_is_used_on_products')
+ is_custom = fields.Boolean('Is custom value', help="Allow users to input custom values for this attribute value")
+ html_color = fields.Char(
+ string='Color',
+ help="Here you can set a specific HTML color index (e.g. #ff0000) to display the color if the attribute type is 'Color'.")
+ display_type = fields.Selection(related='attribute_id.display_type', readonly=True)
+
_sql_constraints = [
('value_company_uniq', 'unique (name, attribute_id)', "You cannot create two values with the same name for the same attribute.")
]
@@ -408,6 +418,10 @@ class ProductTemplateAttributeValue(models.Model):
attribute_id = fields.Many2one('product.attribute', string="Attribute", related='attribute_line_id.attribute_id', store=True, index=True)
ptav_product_variant_ids = fields.Many2many('product.product', relation='product_variant_combination', string="Related Variants", readonly=True)
+ html_color = fields.Char('HTML Color Index', related="product_attribute_value_id.html_color")
+ is_custom = fields.Boolean('Is custom value', related="product_attribute_value_id.is_custom")
+ display_type = fields.Selection(related='product_attribute_value_id.display_type', readonly=True)
+
_sql_constraints = [
('attribute_value_unique', 'unique(attribute_line_id, product_attribute_value_id)', "Each value should be defined only once per attribute per product."),
]
@@ -540,3 +554,21 @@ class ProductTemplateAttributeExclusion(models.Model):
value_ids = fields.Many2many(
'product.template.attribute.value', relation="product_attr_exclusion_value_ids_rel",
string='Attribute Values', domain="[('product_tmpl_id', '=', product_tmpl_id), ('ptav_active', '=', True)]")
+
+
+class ProductAttributeCustomValue(models.Model):
+ _name = "product.attribute.custom.value"
+ _description = 'Product Attribute Custom Value'
+ _order = 'custom_product_template_attribute_value_id, id'
+
+ name = fields.Char("Name", compute='_compute_name')
+ custom_product_template_attribute_value_id = fields.Many2one('product.template.attribute.value', string="Attribute Value", required=True, ondelete='restrict')
+ custom_value = fields.Char("Custom Value")
+
+ @api.depends('custom_product_template_attribute_value_id.name', 'custom_value')
+ def _compute_name(self):
+ for record in self:
+ name = (record.custom_value or '').strip()
+ if record.custom_product_template_attribute_value_id.display_name:
+ name = "%s: %s" % (record.custom_product_template_attribute_value_id.display_name, name)
+ record.name = name
diff --git a/addons/product/security/ir.model.access.csv b/addons/product/security/ir.model.access.csv
index 19aa27fa1bc..a342bfe5ac0 100644
--- a/addons/product/security/ir.model.access.csv
+++ b/addons/product/security/ir.model.access.csv
@@ -9,6 +9,7 @@ access_product_pricelist_partner_manager,product.pricelist partner manager,model
access_product_product_employee,product.product employee,model_product_product,base.group_user,1,0,0,0
access_product_attribute,product.attribute,model_product_attribute,base.group_user,1,0,0,0
access_product_attribute_value,product.attribute value,model_product_attribute_value,base.group_user,1,0,0,0
+access_product_attribute_custom_value_user,product.attribute.custom value manager,model_product_attribute_custom_value,base.group_user,1,0,0,0
access_product_product_attribute,product.template.attribute value,model_product_template_attribute_value,base.group_user,1,0,0,0
access_product_template_attribute_exclusion,product..template.attribute exclusion,model_product_template_attribute_exclusion,base.group_user,1,0,0,0
access_product_template_attribute_line,product.template.attribute line,model_product_template_attribute_line,base.group_user,1,0,0,0
@@ -21,6 +22,7 @@ access_product_pricelist_item_manager,product.pricelist.item.manager,model_produ
access_product_product_manager,product.product manager,model_product_product,base.group_system,1,1,1,1
access_product_attribute_manager,product.attribute manager,model_product_attribute,base.group_system,1,1,1,1
access_product_attribute_value_manager,product.attribute value manager,model_product_attribute_value,base.group_system,1,1,1,1
+access_product_attribute_custom_value_manager,product.attribute.custom value manager,model_product_attribute_custom_value,base.group_system,1,1,1,1
access_product_product_attribute_manager,product.template.attribute value manager,model_product_template_attribute_value,base.group_system,1,1,1,1
access_product_template_attribute_exclusion_manager,product.template.attribute exclusion manager,model_product_template_attribute_exclusion,base.group_system,1,1,1,1
access_product_template_attribute_line_manager,product.template.attribute line manager,model_product_template_attribute_line,base.group_system,1,1,1,1
diff --git a/addons/product/views/product_attribute_views.xml b/addons/product/views/product_attribute_views.xml
index 63ad0debf6c..c8f1eba860e 100644
--- a/addons/product/views/product_attribute_views.xml
+++ b/addons/product/views/product_attribute_views.xml
@@ -7,6 +7,7 @@
<tree string="Variant Values">
<field name="sequence" widget="handle"/>
<field name="name"/>
+ <field name="display_type"/>
<field name="create_variant"/>
</tree>
</field>
@@ -22,6 +23,7 @@
<group name="main_fields" class="o_label_nowrap">
<label for="name" string="Attribute Name"/>
<field name="name" nolabel="1"/>
+ <field name="display_type" widget="radio"/>
<field name="create_variant" widget="radio" attrs="{'readonly': [('is_used_on_products', '=', True)]}"/>
</group>
<notebook>
@@ -30,6 +32,9 @@
<tree string="Values" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
+ <field name="display_type" invisible="1"/>
+ <field name="is_custom" groups="product.group_product_variant"/>
+ <field name="html_color" attrs="{'column_invisible': [('parent.display_type', '!=', 'color')]}" widget="color"/>
</tree>
</field>
</page>
@@ -66,6 +71,7 @@
<field name="value_ids" widget="one2many">
<tree string="Values">
<field name="name"/>
+ <field name="html_color"/>
</tree>
<form string="Values">
<field name="name"/>
@@ -84,6 +90,8 @@
<tree string="Attributes" create="0" delete="0">
<field name="attribute_id"/>
<field name="name"/>
+ <field name="display_type"/>
+ <field name="html_color" attrs="{'invisible': [('display_type', '!=', 'color')]}" widget="color"/>
<field name="ptav_active" optional="hide"/>
<field name="price_extra" widget="monetary"/>
<field name="currency_id" invisible="1"/>
@@ -101,6 +109,8 @@
<group>
<field name="ptav_active" readonly="1" attrs="{'invisible': [('ptav_active', '=', True)]}"/>
<field name="name"/>
+ <field name="display_type" invisible="1"/>
+ <field name="html_color" attrs="{'invisible': [('display_type', '!=', 'color')]}"/>
<field name="price_extra" widget="monetary"/>
<field name="currency_id" invisible="1"/>
<field name="exclude_for" widget="one2many" mode="tree">
diff --git a/addons/sale/__manifest__.py b/addons/sale/__manifest__.py
index abe0dd27887..944b67a62b2 100644
--- a/addons/sale/__manifest__.py
+++ b/addons/sale/__manifest__.py
@@ -32,7 +32,6 @@ This module contains all the common features of Sales Management and eCommerce.
'views/sale_onboarding_views.xml',
'views/res_config_settings_views.xml',
'views/payment_views.xml',
- 'views/product_attribute_views.xml',
'views/product_views.xml',
'views/utm_campaign_views.xml',
'wizard/sale_order_cancel_views.xml',
diff --git a/addons/sale/models/product_product.py b/addons/sale/models/product_product.py
index df7f83de1c2..2808b3034e1 100644
--- a/addons/sale/models/product_product.py
+++ b/addons/sale/models/product_product.py
@@ -57,50 +57,10 @@ class ProductProduct(models.Model):
return self.product_tmpl_id._get_combination_info(self.product_template_attribute_value_ids, self.id, add_qty, pricelist, parent_combination)
-class ProductAttribute(models.Model):
- _inherit = "product.attribute"
-
- display_type = fields.Selection([
- ('radio', 'Radio'),
- ('select', 'Select'),
- ('color', 'Color')], default='radio', required=True, help="The display type used in the Product Configurator.")
-
-
-class ProductAttributeValue(models.Model):
- _inherit = "product.attribute.value"
-
- is_custom = fields.Boolean('Is custom value', help="Allow users to input custom values for this attribute value")
- html_color = fields.Char(
- string='Color',
- help="Here you can set a specific HTML color index (e.g. #ff0000) to display the color if the attribute type is 'Color'.")
- display_type = fields.Selection(related='attribute_id.display_type', readonly=True)
-
-
-class ProductTemplateAttributeValue(models.Model):
- _inherit = "product.template.attribute.value"
-
- html_color = fields.Char('HTML Color Index', related="product_attribute_value_id.html_color")
- is_custom = fields.Boolean('Is custom value', related="product_attribute_value_id.is_custom")
- display_type = fields.Selection(related='product_attribute_value_id.display_type', readonly=True)
-
-
class ProductAttributeCustomValue(models.Model):
- _name = "product.attribute.custom.value"
- _description = 'Product Attribute Custom Value'
- _order = 'custom_product_template_attribute_value_id, id'
+ _inherit = "product.attribute.custom.value"
- name = fields.Char("Name", compute='_compute_name')
- custom_product_template_attribute_value_id = fields.Many2one('product.template.attribute.value', string="Attribute Value", required=True, ondelete='restrict')
sale_order_line_id = fields.Many2one('sale.order.line', string="Sales Order Line", required=True, ondelete='cascade')
- custom_value = fields.Char("Custom Value")
-
- @api.depends('custom_product_template_attribute_value_id.name', 'custom_value')
- def _compute_name(self):
- for record in self:
- name = (record.custom_value or '').strip()
- if record.custom_product_template_attribute_value_id.display_name:
- name = "%s: %s" % (record.custom_product_template_attribute_value_id.display_name, name)
- record.name = name
_sql_constraints = [
('sol_custom_value_unique', 'unique(custom_product_template_attribute_value_id, sale_order_line_id)', "Only one Custom Value is allowed per Attribute Value per Sales Order Line.")
diff --git a/addons/sale/security/ir.model.access.csv b/addons/sale/security/ir.model.access.csv
index e380e0d77a1..471430ad125 100644
--- a/addons/sale/security/ir.model.access.csv
+++ b/addons/sale/security/ir.model.access.csv
@@ -43,7 +43,7 @@ access_product_product_sale_manager,product.product salemanager,model_product_pr
access_product_attribute_sale_manager,product.attribute manager,product.model_product_attribute,sales_team.group_sale_manager,1,1,1,1
access_product_attribute_value_sale_manager,product.attribute manager value,product.model_product_attribute_value,sales_team.group_sale_manager,1,1,1,1
access_product_product_attribute_sale_manager,product.template.attribute manager value,product.model_product_template_attribute_value,sales_team.group_sale_manager,1,1,1,1
-access_product_product_attribute_custom_value,product.attribute.custom value manager,model_product_attribute_custom_value,sales_team.group_sale_salesman,1,1,1,1
+access_product_product_attribute_custom_value_sale_manager,product.attribute.custom value manager,product.model_product_attribute_custom_value,sales_team.group_sale_salesman,1,1,1,1
access_product_template_attribute_exclusion_sale_manager,product.attribute manager filter line,product.model_product_template_attribute_exclusion,sales_team.group_sale_manager,1,1,1,1
access_product_template_attribute_line_sale_manager,product.attribute manager line,product.model_product_template_attribute_line,sales_team.group_sale_manager,1,1,1,1
access_account_tax_sale_manager,account.tax sale manager,account.model_account_tax,sales_team.group_sale_salesman,1,0,0,0
diff --git a/addons/sale/views/product_attribute_views.xml b/addons/sale/views/product_attribute_views.xml
deleted file mode 100644
index 11c740f3349..00000000000
--- a/addons/sale/views/product_attribute_views.xml
+++ /dev/null
@@ -1,68 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<odoo>
- <!-- Product attribute -->
-
- <record id="attribute_tree_view" model="ir.ui.view">
- <field name="name">product.attribute.tree.type</field>
- <field name="model">product.attribute</field>
- <field name="inherit_id" ref="product.attribute_tree_view"></field>
- <field name="arch" type="xml">
- <field name="name" position="after">
- <field name="display_type"/>
- </field>
- </field>
- </record>
-
- <record id="product_template_attribute_line_form" model="ir.ui.view">
- <field name="name">product.template.attribute.line.form.inherit</field>
- <field name="model">product.template.attribute.line</field>
- <field name="inherit_id" ref="product.product_template_attribute_line_form"/>
- <field name="priority" eval="8"/>
- <field name="arch" type="xml">
- <xpath expr="//field[@name='value_ids']/tree/field[@name='name']" position="after">
- <field name="html_color"/>
- </xpath>
- </field>
- </record>
-
- <record id="product_attribute_view_form" model="ir.ui.view">
- <field name="name">product.attribute.form.inherit</field>
- <field name="model">product.attribute</field>
- <field name="inherit_id" ref="product.product_attribute_view_form"/>
- <field name="priority" eval="8"/>
- <field name="arch" type="xml">
- <xpath expr="//field[@name='create_variant']" position="before">
- <field name="display_type" widget="radio"/>
- </xpath>
- <xpath expr="//field[@name='value_ids']/tree/field[@name='name']" position="after">
- <field name="display_type" invisible="1"/>
- <field name="is_custom" groups="product.group_product_variant"/>
- <field name="html_color" attrs="{'column_invisible': [('parent.display_type', '!=', 'color')]}" widget="color"/>
- </xpath>
- </field>
- </record>
-
- <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">
- <field name="name" position="after">
- <field name="display_type"/>
- <field name="html_color" attrs="{'invisible': [('display_type', '!=', 'color')]}" widget="color"/>
- </field>
- </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">
- <field name="name" position="after">
- <field name="display_type" invisible="1"/>
- <field name="html_color" attrs="{'invisible': [('display_type', '!=', 'color')]}"/>
- </field>
- </field>
- </record>
-</odoo> \ No newline at end of file