[ADD] product: add multi-checkbox attribute type for extra options
Currently there is no efficient way to suggest extra-options to customers in PoS or eCommerce. Extra options should be easily selected by customers (or PoS waiters) with checkbox. Each checked option would add an extra-price to the product price and appear on tickets, receipts, invoices, kitchen display, ... This commit introduces a new type of attribute: multi-checkbox. This will not create product variants. A default extra-cost is defined at the attribute level, but it can be changed at the product level. The new attribute is available in Sales, not yet in eCommerce nor in PoS (it will be in a future task). task-3256585 closes odoo/odoo#130915 Signed-off-by: Victor Feyens (vfe) <vfe@odoo.com>
This commit is contained in:
@@ -37,6 +37,7 @@ Print product labels with barcode.
|
||||
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/product_attribute_views.xml',
|
||||
'views/product_attribute_value_views.xml',
|
||||
'views/product_category_views.xml',
|
||||
'views/product_document_views.xml',
|
||||
'views/product_packaging_views.xml',
|
||||
|
||||
@@ -11,6 +11,14 @@ class ProductAttribute(models.Model):
|
||||
# `_sort_key_attribute_value` in `product.template`
|
||||
_order = 'sequence, id'
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
'check_multi_checkbox_no_variant',
|
||||
"CHECK(display_type != 'multi' OR create_variant = 'no_variant')",
|
||||
"Multi-checkbox display type is not compatible with the creation of variants"
|
||||
),
|
||||
]
|
||||
|
||||
name = fields.Char(string="Attribute", required=True, translate=True)
|
||||
create_variant = fields.Selection(
|
||||
selection=[
|
||||
@@ -31,6 +39,7 @@ class ProductAttribute(models.Model):
|
||||
('pills', 'Pills'),
|
||||
('select', 'Select'),
|
||||
('color', 'Color'),
|
||||
('multi', 'Multi-checkbox (option)'),
|
||||
],
|
||||
default='radio',
|
||||
required=True,
|
||||
|
||||
@@ -34,6 +34,7 @@ class ProductAttributeValue(models.Model):
|
||||
is_used_on_products = fields.Boolean(
|
||||
string="Used on Products", compute='_compute_is_used_on_products')
|
||||
|
||||
default_extra_price = fields.Float()
|
||||
is_custom = fields.Boolean(
|
||||
string="Is custom value",
|
||||
help="Allow users to input custom values for this attribute value")
|
||||
|
||||
@@ -1257,7 +1257,9 @@ class ProductTemplate(models.Model):
|
||||
|
||||
necessary_values = necessary_values or self.env['product.template.attribute.value']
|
||||
necessary_attribute_lines = necessary_values.mapped('attribute_line_id')
|
||||
attribute_lines = self.valid_product_template_attribute_line_ids.filtered(lambda ptal: ptal not in necessary_attribute_lines)
|
||||
attribute_lines = self.valid_product_template_attribute_line_ids.filtered(
|
||||
lambda ptal: ptal not in necessary_attribute_lines and (
|
||||
ptal.attribute_id.display_type != 'multi'))
|
||||
|
||||
if not attribute_lines and self._is_combination_possible(necessary_values, parent_combination):
|
||||
yield necessary_values
|
||||
|
||||
@@ -235,7 +235,8 @@ class ProductTemplateAttributeLine(models.Model):
|
||||
# create values that didn't exist yet
|
||||
ptav_to_create.append({
|
||||
'product_attribute_value_id': pav.id,
|
||||
'attribute_line_id': ptal.id
|
||||
'attribute_line_id': ptal.id,
|
||||
'price_extra': pav.default_extra_price,
|
||||
})
|
||||
# Handle active at each step in case a following line might want to
|
||||
# re-use a value that was archived at a previous step.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="product_attribute_value_list" model="ir.ui.view">
|
||||
<field name="name">product.attribute.value.list</field>
|
||||
<field name="model">product.attribute.value</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Attribute Values">
|
||||
<field name="name"/>
|
||||
<field name="default_extra_price"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -30,11 +30,13 @@
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<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" readonly="number_related_products != 0"/>
|
||||
<group name="main_fields">
|
||||
<group name="sale_main_fields">
|
||||
<label for="name" string="Attribute Name"/>
|
||||
<field name="name" nolabel="1"/>
|
||||
<field name="display_type" widget="radio"/>
|
||||
<field name="create_variant" widget="radio" readonly="number_related_products != 0"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Attribute Values" name="attribute_values">
|
||||
@@ -43,8 +45,9 @@
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="display_type" column_invisible="True"/>
|
||||
<field name="is_custom" groups="product.group_product_variant"/>
|
||||
<field name="is_custom" groups="product.group_product_variant" column_invisible="parent.display_type == 'multi'"/>
|
||||
<field name="html_color" column_invisible="parent.display_type != 'color'" widget="color"/>
|
||||
<field name="default_extra_price"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
@@ -9,7 +9,7 @@ from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.fields import Command
|
||||
from odoo.osv import expression
|
||||
from odoo.tools import float_is_zero, float_compare, float_round, format_date
|
||||
from odoo.tools import float_is_zero, float_compare, float_round, format_date, groupby
|
||||
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
@@ -365,12 +365,21 @@ class SaleOrderLine(models.Model):
|
||||
|
||||
custom_ptavs = self.product_custom_attribute_value_ids.custom_product_template_attribute_value_id
|
||||
no_variant_ptavs = self.product_no_variant_attribute_value_ids._origin
|
||||
multi_ptavs = no_variant_ptavs.filtered(lambda ptav: ptav.display_type == 'multi').sorted()
|
||||
|
||||
# display the no_variant attributes, except those that are also
|
||||
# displayed by a custom (avoid duplicate description)
|
||||
for ptav in (no_variant_ptavs - custom_ptavs):
|
||||
for ptav in (no_variant_ptavs - multi_ptavs - custom_ptavs):
|
||||
name += "\n" + ptav.display_name
|
||||
|
||||
# display the selected values per attribute on a single for a multi checkbox
|
||||
for pta, ptavs in groupby(multi_ptavs, lambda ptav: ptav.attribute_id):
|
||||
name += "\n" + _(
|
||||
"%(attribute)s: %(values)s",
|
||||
attribute=pta.name,
|
||||
values=", ".join(ptav.name for ptav in ptavs)
|
||||
)
|
||||
|
||||
# Sort the values according to _order settings, because it doesn't work for virtual records in onchange
|
||||
sorted_custom_ptav = self.product_custom_attribute_value_ids.custom_product_template_attribute_value_id.sorted()
|
||||
for patv in sorted_custom_ptav:
|
||||
|
||||
@@ -280,9 +280,9 @@ class ProductConfiguratorController(Controller):
|
||||
) for ptav in ptal.product_template_value_ids
|
||||
if ptav.ptav_active
|
||||
],
|
||||
selected_attribute_value_id=combination.filtered(
|
||||
selected_attribute_value_ids=combination.filtered(
|
||||
lambda c: ptal in c.attribute_line_id
|
||||
).id,
|
||||
).ids,
|
||||
create_variant=ptal.attribute_id.create_variant,
|
||||
) for ptal in product_template.attribute_line_ids],
|
||||
exclusions=attribute_exclusions['exclusions'],
|
||||
|
||||
+20
-9
@@ -206,17 +206,28 @@ export class ProductConfiguratorDialog extends Component {
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the value of `selected_attribute_value_id` on the given PTAL in the product.
|
||||
* Change the value of `selected_attribute_value_ids` on the given PTAL in the product.
|
||||
*
|
||||
* @param {Number} productTmplId - The product template id, as a `product.template` id.
|
||||
* @param {Number} ptalId - The PTAL id, as a `product.template.attribute.line` id.
|
||||
* @param {Number} ptavId - The PTAV id, as a `product.template.attribute.value` id.
|
||||
* @param {Boolean} multiIdsAllowed - Whether multiple `product.template.attribute.value` can be selected.
|
||||
*/
|
||||
async _updateProductTemplateSelectedPTAV(productTmplId, ptalId, ptavId) {
|
||||
async _updateProductTemplateSelectedPTAV(productTmplId, ptalId, ptavId, multiIdsAllowed) {
|
||||
const product = this._findProduct(productTmplId);
|
||||
product.attribute_lines.find(
|
||||
ptal => ptal.id === ptalId
|
||||
).selected_attribute_value_id = parseInt(ptavId);
|
||||
let selectedIds = product.attribute_lines.find(ptal => ptal.id === ptalId).selected_attribute_value_ids;
|
||||
if (multiIdsAllowed) {
|
||||
const ptavID = parseInt(ptavId);
|
||||
if (!selectedIds.includes(ptavID)){
|
||||
selectedIds.push(ptavID);
|
||||
} else {
|
||||
selectedIds = selectedIds.filter(ptav => ptav !== ptavID);
|
||||
}
|
||||
|
||||
} else {
|
||||
selectedIds = [parseInt(ptavId)];
|
||||
}
|
||||
product.attribute_lines.find(ptal => ptal.id === ptalId).selected_attribute_value_ids = selectedIds;
|
||||
this._checkExclusions(product);
|
||||
if (this._isPossibleCombination(product)) {
|
||||
const updatedValues = await this._updateCombination(product, product.quantity);
|
||||
@@ -234,7 +245,7 @@ export class ProductConfiguratorDialog extends Component {
|
||||
_updatePTAVCustomValue(productTmplId, ptavId, customValue) {
|
||||
const product = this._findProduct(productTmplId);
|
||||
product.attribute_lines.find(
|
||||
ptal => ptal.selected_attribute_value_id === ptavId
|
||||
ptal => ptal.selected_attribute_value_ids.includes(ptavId)
|
||||
).customValue = customValue;
|
||||
}
|
||||
|
||||
@@ -328,7 +339,7 @@ export class ProductConfiguratorDialog extends Component {
|
||||
* @return {Array} - The combination of the product.
|
||||
*/
|
||||
_getCombination(product) {
|
||||
return product.attribute_lines.map(ptal => ptal.selected_attribute_value_id);
|
||||
return product.attribute_lines.flatMap(ptal => ptal.selected_attribute_value_ids);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -354,8 +365,8 @@ export class ProductConfiguratorDialog extends Component {
|
||||
*/
|
||||
_isPossibleCombination(product) {
|
||||
return product.attribute_lines.every(ptal => !ptal.attribute_values.find(
|
||||
ptav => ptav.id === ptal.selected_attribute_value_id
|
||||
).excluded);
|
||||
ptav => ptal.selected_attribute_value_ids.includes(ptav.id)
|
||||
)?.excluded);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+10
-7
@@ -17,7 +17,7 @@ export class ProductTemplateAttributeLine extends Component {
|
||||
name: String,
|
||||
display_type: {
|
||||
type: String,
|
||||
validate: type => ["color", "pills", "radio", "select"].includes(type),
|
||||
validate: type => ["color", "multi", "pills", "radio", "select"].includes(type),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -35,7 +35,7 @@ export class ProductTemplateAttributeLine extends Component {
|
||||
},
|
||||
},
|
||||
},
|
||||
selected_attribute_value_id: Number,
|
||||
selected_attribute_value_ids: { type: Array, element: Number },
|
||||
create_variant: {
|
||||
type: String,
|
||||
validate: type => ["always", "dynamic", "no_variant"].includes(type),
|
||||
@@ -54,7 +54,7 @@ export class ProductTemplateAttributeLine extends Component {
|
||||
*/
|
||||
updateSelectedPTAV(event) {
|
||||
this.env.updateProductTemplateSelectedPTAV(
|
||||
this.props.productTmplId, this.props.id, event.target.value
|
||||
this.props.productTmplId, this.props.id, event.target.value, this.props.attribute.display_type == 'multi'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export class ProductTemplateAttributeLine extends Component {
|
||||
*/
|
||||
updateCustomValue(event) {
|
||||
this.env.updatePTAVCustomValue(
|
||||
this.props.productTmplId, this.props.selected_attribute_value_id, event.target.value
|
||||
this.props.productTmplId, this.props.selected_attribute_value_ids[0], event.target.value
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,11 +76,12 @@ export class ProductTemplateAttributeLine extends Component {
|
||||
/**
|
||||
* Return template name to use by checking the display type in the props.
|
||||
*
|
||||
* Each attribute line can have one of this four display types:
|
||||
* Each attribute line can have one of this five display types:
|
||||
* - 'Color' : Display each attribute as a circle filled with said color.
|
||||
* - 'Pills' : Display each attribute as a rectangle-shaped element.
|
||||
* - 'Radio' : Display each attribute as a radio element.
|
||||
* - 'Select' : Display each attribute in a selection tag.
|
||||
* - 'Multi' : Display each attribute in a multi-checkbox tag.
|
||||
*
|
||||
* @return {String} - The template name to use.
|
||||
*/
|
||||
@@ -88,6 +89,8 @@ export class ProductTemplateAttributeLine extends Component {
|
||||
switch(this.props.attribute.display_type) {
|
||||
case 'color':
|
||||
return 'saleProductConfigurator.ptav-color';
|
||||
case 'multi':
|
||||
return 'saleProductConfigurator.ptav-multi';
|
||||
case 'pills':
|
||||
return 'saleProductConfigurator.ptav-pills';
|
||||
case 'radio':
|
||||
@@ -126,7 +129,7 @@ export class ProductTemplateAttributeLine extends Component {
|
||||
*/
|
||||
isSelectedPTAVCustom() {
|
||||
return this.props.attribute_values.find(
|
||||
ptav => this.props.selected_attribute_value_id === ptav.id
|
||||
).is_custom;
|
||||
ptav => this.props.selected_attribute_value_ids.includes(ptav.id)
|
||||
)?.is_custom;
|
||||
}
|
||||
}
|
||||
|
||||
+36
-6
@@ -29,7 +29,7 @@
|
||||
t-as="ptav"
|
||||
t-key="ptav.id"
|
||||
t-att-value="ptav.id"
|
||||
t-att-selected="ptav.id === this.props.selected_attribute_value_id"
|
||||
t-att-selected="this.props.selected_attribute_value_ids.includes(ptav.id)"
|
||||
t-out="getPTAVSelectName(ptav)"
|
||||
t-att-class="{ 'css_not_available': ptav.excluded }"/>
|
||||
</select>
|
||||
@@ -54,7 +54,7 @@
|
||||
class="form-check-input"
|
||||
t-att-id="ptav.id"
|
||||
t-att-value="ptav.id"
|
||||
t-att-checked="ptav.id === this.props.selected_attribute_value_id"
|
||||
t-att-checked="this.props.selected_attribute_value_ids.includes(ptav.id)"
|
||||
t-att-name="'ptal-' + this.props.id"
|
||||
t-on-change="updateSelectedPTAV"/>
|
||||
</div>
|
||||
@@ -64,7 +64,7 @@
|
||||
<t t-name="saleProductConfigurator.ptav-pills">
|
||||
<ul t-attf-class="list-inline list-unstyled">
|
||||
<li t-foreach="this.props.attribute_values" t-as="ptav" t-key="ptav.id"
|
||||
t-att-class="{'active': ptav.id === this.props.selected_attribute_value_id}"
|
||||
t-att-class="{'active': this.props.selected_attribute_value_ids.includes(ptav.id)}"
|
||||
class="o_sale_product_configurator_ptav_pills btn btn-primary list-inline-item mb-3">
|
||||
<div class="form-check">
|
||||
<label
|
||||
@@ -82,7 +82,7 @@
|
||||
t-att-id="ptav.id"
|
||||
t-att-value="ptav.id"
|
||||
t-att-name="'ptal-' + this.props.id"
|
||||
t-att-checked="ptav.id === this.props.selected_attribute_value_id"
|
||||
t-att-checked="this.props.selected_attribute_value_ids.includes(ptav.id)"
|
||||
t-on-change="updateSelectedPTAV"/>
|
||||
</div>
|
||||
</li>
|
||||
@@ -95,16 +95,46 @@
|
||||
<label
|
||||
t-att-title="ptav.name"
|
||||
t-attf-style="background-color:#{ptav.is_custom ? '' : ptav.html_color}"
|
||||
t-att-class="{'o_sale_product_configurator_ptav_color': true, 'active': ptav.id === this.props.selected_attribute_value_id, 'custom_value': ptav.is_custom, 'transparent': !ptav.is_custom and !ptav.html_color, 'css_not_available': ptav.excluded }">
|
||||
t-att-class="{'o_sale_product_configurator_ptav_color': true,
|
||||
'active': this.props.selected_attribute_value_ids.includes(ptav.id),
|
||||
'custom_value': ptav.is_custom,
|
||||
'transparent': !ptav.is_custom and !ptav.html_color,
|
||||
'css_not_available': ptav.excluded }">
|
||||
<input
|
||||
type="radio"
|
||||
t-att-id="ptav.id"
|
||||
t-att-value="ptav.id"
|
||||
t-att-name="'ptal-' + this.props.id"
|
||||
t-att-checked="ptav.id === this.props.selected_attribute_value_id"
|
||||
t-att-checked="this.props.selected_attribute_value_ids.includes(ptav.id)"
|
||||
t-on-change="updateSelectedPTAV"/>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</t>
|
||||
<t t-name="saleProductConfigurator.ptav-multi" owl="1">
|
||||
<ul t-attf-class="list-inline list-unstyled">
|
||||
<li t-foreach="this.props.attribute_values" t-as="ptav" t-key="ptav.id"
|
||||
class="list-inline-item mb-3">
|
||||
<div class="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="me-1"
|
||||
t-att-id="ptav.id"
|
||||
t-att-value="ptav.id"
|
||||
t-att-name="'ptal-' + this.props.id"
|
||||
t-att-checked="this.props.selected_attribute_value_ids.includes(ptav.id)"
|
||||
t-on-change="updateSelectedPTAV"/>
|
||||
<label
|
||||
class="col-form-label"
|
||||
t-att-for="ptav.id">
|
||||
<span t-out="ptav.name"/>
|
||||
<BadgeExtraPrice
|
||||
t-if="ptav.price_extra"
|
||||
price="ptav.price_extra"
|
||||
currencyId="this.env.currencyId"/>
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
@@ -12,7 +12,7 @@ async function applyProduct(record, product) {
|
||||
const contextRecords = [];
|
||||
for (const ptal of product.attribute_lines) {
|
||||
const selectedCustomPTAV = ptal.attribute_values.find(
|
||||
ptav => ptav.is_custom && ptav.id === ptal.selected_attribute_value_id
|
||||
ptav => ptav.is_custom && ptal.selected_attribute_value_ids.includes(ptav.id)
|
||||
);
|
||||
if (selectedCustomPTAV) {
|
||||
contextRecords.push({
|
||||
@@ -27,7 +27,7 @@ async function applyProduct(record, product) {
|
||||
|
||||
const noVariantPTAVIds = product.attribute_lines.filter(
|
||||
ptal => ptal.create_variant === "no_variant" && ptal.attribute_values.length > 1
|
||||
).map(ptal => ptal.selected_attribute_value_id);
|
||||
).flatMap(ptal => ptal.selected_attribute_value_ids);
|
||||
|
||||
await Promise.all(proms);
|
||||
await record.update({
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
<field name="model">product.attribute</field>
|
||||
<field name="inherit_id" ref="product.product_attribute_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="create_variant" position="after">
|
||||
<field name="visibility" string="eCommerce Filter Visibility" widget="radio"/>
|
||||
</field>
|
||||
<group name="main_fields" position="inside">
|
||||
<group name="ecommerce_main_fields">
|
||||
<field name="visibility" string="eCommerce Filter Visibility" widget="radio"/>
|
||||
</group>
|
||||
</group>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
@@ -20,9 +20,13 @@ class ProductAttribute(models.Model):
|
||||
_inherit = 'product.attribute'
|
||||
_order = 'category_id, sequence, id'
|
||||
|
||||
category_id = fields.Many2one('product.attribute.category', string="Category", index=True,
|
||||
help="Set a category to regroup similar attributes under "
|
||||
"the same section in the Comparison page of eCommerce")
|
||||
category_id = fields.Many2one(
|
||||
comodel_name='product.attribute.category',
|
||||
string="eCommerce Category",
|
||||
index=True,
|
||||
help="Set a category to regroup similar attributes under the same section in the Comparison"
|
||||
" page of eCommerce.",
|
||||
)
|
||||
|
||||
|
||||
class ProductTemplateAttributeLine(models.Model):
|
||||
|
||||
@@ -45,12 +45,12 @@
|
||||
<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="inherit_id" ref="website_sale.product_attribute_view_form"/>
|
||||
<field name="priority" eval="8"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name='name' position='after'>
|
||||
<group name="ecommerce_main_fields" position="inside">
|
||||
<field name="category_id"/>
|
||||
</field>
|
||||
</group>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user