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 @@ + + + + + + Size + 30 + radio + no_variant + + + S + 1 + + + + M + 2 + + + + L + 3 + + + + + + + + + + Fabric + 40 + select + no_variant + + + Plastic + 1 + + + + Leather + 2 + + + + Custom + 3 + + True + + + + + + + True 1.98 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]]; @@ -428,6 +428,53 @@ exports.PosModel = Backbone.Model.extend({ return new exports.Product({}, product); })); }, + },{ + 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'], @@ -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 @@
- +
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 @@ + + + + + + + + +
+
+ + + +
+
+
+ + +
+ + + + + +
+
+ + +
+ + +
    +
  • + + +
  • +
+ + +
+
+ +
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 @@
  • - + 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 @@ + 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 @@ + @@ -18,6 +19,7 @@ + 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 @@
  • +
    +
    + +
    +
    +
    +
    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 @@ - + @@ -58,7 +58,7 @@
    - + 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 @@ + @@ -22,6 +23,7 @@ @@ -30,6 +32,9 @@ + + + @@ -66,6 +71,7 @@ + @@ -84,6 +90,8 @@ + + @@ -101,6 +109,8 @@ + + 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 @@ - - - - - - product.attribute.tree.type - product.attribute - - - - - - - - - - product.template.attribute.line.form.inherit - product.template.attribute.line - - - - - - - - - - - product.attribute.form.inherit - product.attribute - - - - - - - - - - - - - - - - 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