[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:
Valentin Vallaeys (vava)
2023-09-07 11:46:06 +00:00
parent aa0833184e
commit 5ca1da8ab3
16 changed files with 136 additions and 45 deletions
+1
View File
@@ -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")
+3 -1
View File
@@ -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>
+11 -2
View File
@@ -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'],
@@ -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);
}
/**
@@ -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;
}
}
@@ -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>