diff options
Diffstat (limited to 'addons')
41 files changed, 1107 insertions, 842 deletions
diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml index 608265bb51a..7c2e38a6f86 100644 --- a/addons/account/views/account_view.xml +++ b/addons/account/views/account_view.xml @@ -1731,7 +1731,7 @@ action = model.setting_init_bank_account_action() <tree string="Products"> <field name="default_code"/> <field name="name"/> - <field name="attribute_value_ids" widget="many2many_tags" groups="product.group_product_variant"/> + <field name="product_template_attribute_value_ids" widget="many2many_tags" groups="product.group_product_variant"/> <field name="lst_price"/> <field name="taxes_id" widget="many2many_tags"/> <field name="supplier_taxes_id" widget="many2many_tags"/> diff --git a/addons/hr_expense/views/hr_expense_views.xml b/addons/hr_expense/views/hr_expense_views.xml index 57876d7e750..3bca0011c33 100644 --- a/addons/hr_expense/views/hr_expense_views.xml +++ b/addons/hr_expense/views/hr_expense_views.xml @@ -353,7 +353,7 @@ <tree string="Product Variants"> <field name="default_code"/> <field name="name"/> - <field name="attribute_value_ids" widget="many2many_tags" groups="product.group_product_variant"/> + <field name="product_template_attribute_value_ids" widget="many2many_tags" groups="product.group_product_variant"/> <field name="standard_price"/> <field name="uom_id" options="{'no_open': True, 'no_create': True}" groups="uom.group_uom"/> <field name="barcode"/> diff --git a/addons/mrp/models/mrp_bom.py b/addons/mrp/models/mrp_bom.py index 0a66b1f04e8..5b9cc752296 100644 --- a/addons/mrp/models/mrp_bom.py +++ b/addons/mrp/models/mrp_bom.py @@ -74,17 +74,25 @@ class MrpBom(models.Model): def onchange_product_id(self): if self.product_id: for line in self.bom_line_ids: - line.attribute_value_ids = False + line.bom_product_template_attribute_value_ids = False @api.constrains('product_id', 'product_tmpl_id', 'bom_line_ids') - def _check_product_recursion(self): + def _check_bom_lines(self): for bom in self: - if bom.product_id: - if bom.bom_line_ids.filtered(lambda x: x.product_id == bom.product_id): - raise ValidationError(_('BoM line product %s should not be same as BoM product.') % bom.display_name) - else: - if bom.bom_line_ids.filtered(lambda x: x.product_id.product_tmpl_id == bom.product_tmpl_id): - raise ValidationError(_('BoM line product %s should not be same as BoM product.') % bom.display_name) + for bom_line in bom.bom_line_ids: + if bom.product_id and bom_line.product_id == bom.product_id: + raise ValidationError(_("BoM line product %s should not be the same as BoM product.") % bom.display_name) + if bom_line.product_tmpl_id == bom.product_tmpl_id: + raise ValidationError(_("BoM line product %s should not be the same as BoM product.") % bom.display_name) + if bom.product_id and bom_line.bom_product_template_attribute_value_ids: + raise ValidationError(_("BoM cannot concern product %s and have a line with attributes (%s) at the same time.") + % (bom.product_id.display_name, ", ".join([ptav.display_name for ptav in bom_line.bom_product_template_attribute_value_ids]))) + for ptav in bom_line.bom_product_template_attribute_value_ids: + if ptav.product_tmpl_id != bom.product_tmpl_id: + raise ValidationError( + _("The attribute value %s set on product %s does not match the BoM product %s.") % + (ptav.display_name, ptav.product_tmpl_id.display_name, bom_line.parent_product_tmpl_id.display_name) + ) @api.onchange('product_uom_id') def onchange_product_uom_id(self): @@ -103,7 +111,7 @@ class MrpBom(models.Model): if self.product_id.product_tmpl_id != self.product_tmpl_id: self.product_id = False for line in self.bom_line_ids: - line.attribute_value_ids = False + line.bom_product_template_attribute_value_ids = False @api.onchange('routing_id') def onchange_routing_id(self): @@ -248,10 +256,11 @@ class MrpBomLine(models.Model): 'mrp.bom', 'Parent BoM', index=True, ondelete='cascade', required=True) parent_product_tmpl_id = fields.Many2one('product.template', 'Parent Product Template', related='bom_id.product_tmpl_id') - valid_product_attribute_value_ids = fields.Many2many('product.attribute.value', related='bom_id.product_tmpl_id.valid_product_attribute_value_ids') - attribute_value_ids = fields.Many2many( - 'product.attribute.value', string='Apply on Variants', - help="BOM Product Variants needed form apply this line.") + possible_bom_product_template_attribute_value_ids = fields.Many2many('product.template.attribute.value', compute='_compute_possible_bom_product_template_attribute_value_ids') + bom_product_template_attribute_value_ids = fields.Many2many( + 'product.template.attribute.value', string="Apply on Variants", ondelete='restrict', + domain="[('id', 'in', possible_bom_product_template_attribute_value_ids)]", + help="BOM Product Variants needed to apply this line.") operation_id = fields.Many2one( 'mrp.routing.workcenter', 'Consumed in Operation', help="The operation where the components are consumed, or the finished products created.") @@ -268,6 +277,15 @@ class MrpBomLine(models.Model): 'You should install the mrp_byproduct module if you want to manage extra products on BoMs !'), ] + @api.depends( + 'parent_product_tmpl_id.attribute_line_ids.value_ids', + 'parent_product_tmpl_id.attribute_line_ids.attribute_id.create_variant', + 'parent_product_tmpl_id.attribute_line_ids.product_template_value_ids.ptav_active', + ) + def _compute_possible_bom_product_template_attribute_value_ids(self): + for line in self: + line.possible_bom_product_template_attribute_value_ids = line.parent_product_tmpl_id.valid_product_template_attribute_line_ids._without_no_variant_attributes().product_template_value_ids._only_active() + @api.depends('product_id', 'bom_id') def _compute_child_bom_id(self): for line in self: @@ -309,15 +327,6 @@ class MrpBomLine(models.Model): if self.product_id: self.product_uom_id = self.product_id.uom_id.id - @api.onchange('parent_product_tmpl_id') - def onchange_parent_product(self): - if not self.parent_product_tmpl_id: - return {} - return {'domain': {'attribute_value_ids': [ - ('id', 'in', self.parent_product_tmpl_id.valid_product_attribute_value_ids.ids), - ('attribute_id.create_variant', '!=', 'no_variant') - ]}} - @api.model_create_multi def create(self, vals_list): for values in vals_list: @@ -326,13 +335,17 @@ class MrpBomLine(models.Model): return super(MrpBomLine, self).create(vals_list) def _skip_bom_line(self, product): - """ Control if a BoM line should be produce, can be inherited for add + """ Control if a BoM line should be produced, can be inherited to add custom control. It currently checks that all variant values are in the - product. """ - if self.attribute_value_ids: - for att, att_values in groupby(self.attribute_value_ids, lambda l: l.attribute_id): - values = self.env['product.attribute.value'].concat(*list(att_values)) - if not (product.attribute_value_ids & values): + product. + + If multiple values are encoded for the same attribute line, only one of + them has to be found on the variant. + """ + self.ensure_one() + if self.bom_product_template_attribute_value_ids: + for ptal, iter_ptav in groupby(self.bom_product_template_attribute_value_ids.sorted('attribute_line_id'), lambda ptav: ptav.attribute_line_id): + if not any([ptav in product.product_template_attribute_value_ids for ptav in iter_ptav]): return True return False diff --git a/addons/mrp/tests/test_bom.py b/addons/mrp/tests/test_bom.py index 5ed74a184cc..d57a3a100cf 100644 --- a/addons/mrp/tests/test_bom.py +++ b/addons/mrp/tests/test_bom.py @@ -9,7 +9,7 @@ from odoo.tools import float_compare, float_round class TestBoM(TestMrpCommon): - def test_explode(self): + def test_01_explode(self): boms, lines = self.bom_1.explode(self.product_4, 3) self.assertEqual(set([bom[0].id for bom in boms]), set(self.bom_1.ids)) self.assertEqual(set([line[0].id for line in lines]), set(self.bom_1.bom_line_ids.ids)) @@ -20,7 +20,7 @@ class TestBoM(TestMrpCommon): set([line[0].id for line in lines]), set((self.bom_2 | self.bom_3).mapped('bom_line_ids').filtered(lambda line: not line.child_bom_id or line.child_bom_id.type != 'phantom').ids)) - def test_variants(self): + def test_10_variants(self): test_bom = self.env['mrp.bom'].create({ 'product_id': self.product_7_3.id, 'product_tmpl_id': self.product_7_template.id, @@ -38,13 +38,13 @@ class TestBoM(TestMrpCommon): 'bom_id': test_bom.id, 'product_id': self.product_3.id, 'product_qty': 2, - 'attribute_value_ids': [(4, self.prod_attr1_v1.id)], + 'bom_product_template_attribute_value_ids': [(4, self.product_7_attr1_v1.id)], }) test_bom_l3 = self.env['mrp.bom.line'].create({ 'bom_id': test_bom.id, 'product_id': self.product_4.id, 'product_qty': 2, - 'attribute_value_ids': [(4, self.prod_attr1_v2.id)], + 'bom_product_template_attribute_value_ids': [(4, self.product_7_attr1_v2.id)], }) boms, lines = test_bom.explode(self.product_7_3, 4) self.assertIn(test_bom, [b[0]for b in boms]) @@ -64,7 +64,7 @@ class TestBoM(TestMrpCommon): self.assertNotIn(test_bom_l2, [l[0] for l in lines]) self.assertIn(test_bom_l3, [l[0] for l in lines]) - def test_multi_level_variants(self): + def test_11_multi_level_variants(self): tmp_picking_type = self.env['stock.picking.type'].create({ 'name': 'Manufacturing', 'code': 'mrp_operation', @@ -104,13 +104,13 @@ class TestBoM(TestMrpCommon): 'bom_id': test_bom_2.id, 'product_id': self.product_5.id, 'product_qty': 2, - 'attribute_value_ids': [(4, self.prod_attr1_v1.id)], + 'bom_product_template_attribute_value_ids': [(4, self.product_7_attr1_v1.id)], }) test_bom_2_l3 = self.env['mrp.bom.line'].create({ 'bom_id': test_bom_2.id, 'product_id': self.product_5.id, 'product_qty': 2, - 'attribute_value_ids': [(4, self.prod_attr1_v2.id)], + 'bom_product_template_attribute_value_ids': [(4, self.product_7_attr1_v2.id)], }) test_bom_2_l4 = self.env['mrp.bom.line'].create({ 'bom_id': test_bom_2.id, @@ -165,37 +165,45 @@ class TestBoM(TestMrpCommon): with self.assertRaises(exceptions.UserError): test_bom_3.explode(self.product_9, 1) - def test_multi_level_variants2(self): + def test_12_multi_level_variants2(self): """Test skip bom line with same attribute values in bom lines.""" Product = self.env['product.product'] ProductAttribute = self.env['product.attribute'] ProductAttributeValue = self.env['product.attribute.value'] - MrpProduction = self.env['mrp.production'] # Product Attribute - att_color = ProductAttribute.create({'name': 'Color'}) - att_size = ProductAttribute.create({'name': 'size'}) + att_color = ProductAttribute.create({'name': 'Color', 'sequence': 1}) + att_size = ProductAttribute.create({'name': 'size', 'sequence': 2}) # Product Attribute color Value - att_color_red = ProductAttributeValue.create({'name': 'red', 'attribute_id': att_color.id}) - att_color_blue = ProductAttributeValue.create({'name': 'blue', 'attribute_id': att_color.id}) + att_color_red = ProductAttributeValue.create({'name': 'red', 'attribute_id': att_color.id, 'sequence': 1}) + att_color_blue = ProductAttributeValue.create({'name': 'blue', 'attribute_id': att_color.id, 'sequence': 2}) # Product Attribute size Value - att_size_big = ProductAttributeValue.create({'name': 'big', 'attribute_id': att_size.id}) - att_size_medium = ProductAttributeValue.create({'name': 'medium', 'attribute_id': att_size.id}) + att_size_big = ProductAttributeValue.create({'name': 'big', 'attribute_id': att_size.id, 'sequence': 1}) + att_size_medium = ProductAttributeValue.create({'name': 'medium', 'attribute_id': att_size.id, 'sequence': 2}) # Create Template Product product_template = self.env['product.template'].create({ 'name': 'Sofa', - 'attribute_line_ids': [(0, 0, { + 'attribute_line_ids': [ + (0, 0, { 'attribute_id': att_color.id, 'value_ids': [(6, 0, [att_color_red.id, att_color_blue.id])] - }), (0, 0, { + }), + (0, 0, { 'attribute_id': att_size.id, 'value_ids': [(6, 0, [att_size_big.id, att_size_medium.id])] - })] + }) + ] }) + sofa_red = product_template.attribute_line_ids[0].product_template_value_ids[0] + sofa_blue = product_template.attribute_line_ids[0].product_template_value_ids[1] + + sofa_big = product_template.attribute_line_ids[1].product_template_value_ids[0] + sofa_medium = product_template.attribute_line_ids[1].product_template_value_ids[1] + # Create components Of BOM product_A = Product.create({ 'name': 'Wood'}) @@ -207,31 +215,38 @@ class TestBoM(TestMrpCommon): 'product_tmpl_id': product_template.id, 'product_qty': 1.0, 'type': 'normal', - 'bom_line_ids': [(0, 0, { + 'bom_line_ids': [ + (0, 0, { 'product_id': product_A.id, 'product_qty': 1, - 'attribute_value_ids': [(4, att_color_red.id), (4, att_color_blue.id), (4, att_size_big.id)], - }), (0, 0, { + 'bom_product_template_attribute_value_ids': [(4, sofa_red.id), (4, sofa_blue.id), (4, sofa_big.id)], + }), + (0, 0, { 'product_id': product_B.id, 'product_qty': 1, - 'attribute_value_ids': [(4, att_color_red.id), (4, att_color_blue.id)] - })] + 'bom_product_template_attribute_value_ids': [(4, sofa_red.id), (4, sofa_blue.id)] + }) + ] }) - combination = {(att_color_red, att_size_big): [product_A.id, product_B.id] , (att_color_red, att_size_medium): [product_B.id] , (att_color_blue, att_size_big):[product_A.id, product_B.id], (att_color_blue, att_size_medium): [product_B.id]} + dict_consumed_products = { + sofa_red + sofa_big: product_A + product_B, + sofa_red + sofa_medium: product_B, + sofa_blue + sofa_big: product_A + product_B, + sofa_blue + sofa_medium: product_B, + } # Create production order for all variants. - for comb in combination.keys(): - consu_product_ids = combination[comb] - product = product_template.product_variant_ids.filtered(lambda x: all(value in comb for value in x.attribute_value_ids)) + for combination, consumed_products in dict_consumed_products.items(): + product = product_template.product_variant_ids.filtered(lambda p: p.product_template_attribute_value_ids == combination) mrp_order_form = Form(self.env['mrp.production']) mrp_order_form.product_id = product mrp_order = mrp_order_form.save() # Check consumed materials in production order. - self.assertEqual(mrp_order.move_raw_ids.mapped('product_id').ids, consu_product_ids) + self.assertEqual(mrp_order.move_raw_ids.product_id, consumed_products) - def test_bom_report(self): + def test_20_bom_report(self): """ Simulate a crumble receipt with mrp and open the bom structure report and check that data insde are correct. """ @@ -418,7 +433,7 @@ class TestBoM(TestMrpCommon): # total price = 15.51 + crumble_cost + operation_cost(10 + 1.67 = 11.67) = 27.18 + crumble_cost self.assertEqual(float_compare(report_values['lines']['total'], 27.18 + crumble_cost, precision_digits=2), 0, 'Product Bom Price is not correct') - def test_bom_report_variant(self): + def test_21_bom_report_variant(self): """ Test a sub BoM process with multiple variants. BOM 1: product template = car @@ -461,11 +476,13 @@ class TestBoM(TestMrpCommon): 'sequence': 2, }) - self.car_gps_attribute_lines = self.env['product.template.attribute.line'].create({ + self.car_gps_attribute_line = self.env['product.template.attribute.line'].create({ 'product_tmpl_id': self.car.id, 'attribute_id': self.gps_attribute.id, 'value_ids': [(6, 0, [self.gps_yes.id, self.gps_no.id])], }) + self.car_gps_yes = self.car_gps_attribute_line.product_template_value_ids[0] + self.car_gps_no = self.car_gps_attribute_line.product_template_value_ids[1] self.color_attribute = self.env['product.attribute'].create({'name': 'Color', 'sequence': 1}) self.color_red = self.env['product.attribute.value'].create({ @@ -479,13 +496,13 @@ class TestBoM(TestMrpCommon): 'sequence': 2, }) - self.car_color_attribute_lines = self.env['product.template.attribute.line'].create({ + self.car_color_attribute_line = self.env['product.template.attribute.line'].create({ 'product_tmpl_id': self.car.id, 'attribute_id': self.color_attribute.id, 'value_ids': [(6, 0, [self.color_red.id, self.color_blue.id])], }) - - self.car.create_variant_ids() + self.car_color_red = self.car_color_attribute_line.product_template_value_ids[0] + self.car_color_blue = self.car_color_attribute_line.product_template_value_ids[1] # Blue and red paint uom_litre = self.env.ref('uom.product_uom_litre') @@ -494,13 +511,14 @@ class TestBoM(TestMrpCommon): 'uom_id': uom_litre.id, 'uom_po_id': uom_litre.id }) - self.paint_color_attribute_lines = self.env['product.template.attribute.line'].create({ + self.paint_color_attribute_line = self.env['product.template.attribute.line'].create({ 'product_tmpl_id': self.paint.id, 'attribute_id': self.color_attribute.id, 'value_ids': [(6, 0, [self.color_red.id, self.color_blue.id])], }) + self.paint_color_red = self.paint_color_attribute_line.product_template_value_ids[0] + self.paint_color_blue = self.paint_color_attribute_line.product_template_value_ids[1] - self.paint.create_variant_ids() self.paint.product_variant_ids.write({'standard_price': 20}) self.dashboard = self.env['product.template'].create({ @@ -508,18 +526,21 @@ class TestBoM(TestMrpCommon): 'standard_price': 1000, }) - self.dashboard_gps_attribute_lines = self.env['product.template.attribute.line'].create({ + self.dashboard_gps_attribute_line = self.env['product.template.attribute.line'].create({ 'product_tmpl_id': self.dashboard.id, 'attribute_id': self.gps_attribute.id, 'value_ids': [(6, 0, [self.gps_yes.id, self.gps_no.id])], }) - self.dashboard_color_attribute_lines = self.env['product.template.attribute.line'].create({ + self.dashboard_gps_yes = self.dashboard_gps_attribute_line.product_template_value_ids[0] + self.dashboard_gps_no = self.dashboard_gps_attribute_line.product_template_value_ids[1] + + self.dashboard_color_attribute_line = self.env['product.template.attribute.line'].create({ 'product_tmpl_id': self.dashboard.id, 'attribute_id': self.color_attribute.id, 'value_ids': [(6, 0, [self.color_red.id, self.color_blue.id])], }) - - self.dashboard.create_variant_ids() + self.dashboard_color_red = self.dashboard_color_attribute_line.product_template_value_ids[0] + self.dashboard_color_blue = self.dashboard_color_attribute_line.product_template_value_ids[1] self.gps = self.env['product.product'].create({ 'name': 'GPS', @@ -530,72 +551,68 @@ class TestBoM(TestMrpCommon): bom_form_car.product_tmpl_id = self.car bom_form_car.product_qty = 5 with bom_form_car.bom_line_ids.new() as line: - line.product_id = self.env['product.product'].search([('product_tmpl_id', '=', self.paint.id), ('attribute_value_ids', '=', self.color_red.id)]) + line.product_id = self.paint._get_variant_for_combination(self.paint_color_red) line.product_uom_id = uom_litre line.product_qty = 50 + line.bom_product_template_attribute_value_ids.add(self.car_color_red) with bom_form_car.bom_line_ids.new() as line: - line.product_id = self.env['product.product'].search([('product_tmpl_id', '=', self.paint.id), ('attribute_value_ids', '=', self.color_blue.id)]) + line.product_id = self.paint._get_variant_for_combination(self.paint_color_blue) line.product_uom_id = uom_litre line.product_qty = 50 + line.bom_product_template_attribute_value_ids.add(self.car_color_blue) with bom_form_car.bom_line_ids.new() as line: - line.product_id = self.env['product.product'].search([('product_tmpl_id', '=', self.dashboard.id), ('attribute_value_ids', '=', self.gps_yes.id), ('attribute_value_ids', '=', self.color_red.id)]) + line.product_id = self.dashboard._get_variant_for_combination(self.dashboard_gps_yes + self.dashboard_color_red) line.product_qty = 5 + line.bom_product_template_attribute_value_ids.add(self.car_gps_yes) + line.bom_product_template_attribute_value_ids.add(self.car_color_red) with bom_form_car.bom_line_ids.new() as line: - line.product_id = self.env['product.product'].search([('product_tmpl_id', '=', self.dashboard.id), ('attribute_value_ids', '=', self.gps_yes.id), ('attribute_value_ids', '=', self.color_blue.id)]) + line.product_id = self.dashboard._get_variant_for_combination(self.dashboard_gps_yes + self.dashboard_color_blue) line.product_qty = 5 + line.bom_product_template_attribute_value_ids.add(self.car_gps_yes) + line.bom_product_template_attribute_value_ids.add(self.car_color_blue) with bom_form_car.bom_line_ids.new() as line: - line.product_id = self.env['product.product'].search([('product_tmpl_id', '=', self.dashboard.id), ('attribute_value_ids', '=', self.gps_no.id), ('attribute_value_ids', '=', self.color_red.id)]) + line.product_id = self.dashboard._get_variant_for_combination(self.dashboard_gps_no + self.dashboard_color_red) line.product_qty = 5 + line.bom_product_template_attribute_value_ids.add(self.car_gps_no) + line.bom_product_template_attribute_value_ids.add(self.car_color_red) with bom_form_car.bom_line_ids.new() as line: - line.product_id = self.env['product.product'].search([('product_tmpl_id', '=', self.dashboard.id), ('attribute_value_ids', '=', self.gps_no.id), ('attribute_value_ids', '=', self.color_blue.id)]) + line.product_id = self.dashboard._get_variant_for_combination(self.dashboard_gps_no + self.dashboard_color_blue) line.product_qty = 5 + line.bom_product_template_attribute_value_ids.add(self.car_gps_no) + line.bom_product_template_attribute_value_ids.add(self.car_color_blue) bom_car = bom_form_car.save() - for attribute in self.gps_attribute.value_ids | self.color_attribute.value_ids: - bom_car.bom_line_ids.filtered(lambda l: attribute in l.product_id.attribute_value_ids).write({'attribute_value_ids': [(4, attribute.id)]}) - bom_dashboard = Form(self.env['mrp.bom']) bom_dashboard.product_tmpl_id = self.dashboard bom_dashboard.product_qty = 2 with bom_dashboard.bom_line_ids.new() as line: - line.product_id = self.env['product.product'].search([('product_tmpl_id', '=', self.paint.id), ('attribute_value_ids', '=', self.color_red.id)]) + line.product_id = self.paint._get_variant_for_combination(self.paint_color_red) line.product_uom_id = uom_litre line.product_qty = 1 + line.bom_product_template_attribute_value_ids.add(self.dashboard_color_red) with bom_dashboard.bom_line_ids.new() as line: - line.product_id = self.env['product.product'].search([('product_tmpl_id', '=', self.paint.id), ('attribute_value_ids', '=', self.color_blue.id)]) + line.product_id = self.paint._get_variant_for_combination(self.paint_color_blue) line.product_uom_id = uom_litre line.product_qty = 1 + line.bom_product_template_attribute_value_ids.add(self.dashboard_color_blue) with bom_dashboard.bom_line_ids.new() as line: line.product_id = self.gps line.product_qty = 2 + line.bom_product_template_attribute_value_ids.add(self.dashboard_gps_yes) bom_dashboard = bom_dashboard.save() - for attribute in self.color_attribute.value_ids: - bom_dashboard.bom_line_ids.filtered(lambda l: attribute in l.product_id.attribute_value_ids).write({'attribute_value_ids': [(4, attribute.id)]}) - bom_dashboard.bom_line_ids.filtered(lambda l: l.product_id == self.gps).write({'attribute_value_ids': [(4, self.gps_yes.id)]}) + blue_car_with_gps = self.car._get_variant_for_combination(self.car_color_blue + self.car_gps_yes) - blue_car_with_gps = self.env['product.product'].search([ - ('product_tmpl_id', '=', self.car.id), - ('attribute_value_ids', '=', self.gps_yes.id), - ('attribute_value_ids', '=', self.color_blue.id) - ]) report_values = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_car.id, searchQty=1, searchVariant=blue_car_with_gps.id) # Two lines. blue dashboard with gps and blue paint. self.assertEqual(len(report_values['lines']['components']), 2) # 10l of blue paint - blue_paint = self.env['product.product'].search([ - ('product_tmpl_id', '=', self.paint.id), - ('attribute_value_ids', '=', self.color_blue.id) - ]) + blue_paint = self.paint._get_variant_for_combination(self.paint_color_blue) self.assertEqual(blue_paint.id, report_values['lines']['components'][0]['prod_id']) self.assertEqual(report_values['lines']['components'][0]['prod_qty'], 10) # 1 blue dashboard with GPS - blue_dashboard_gps = self.env['product.product'].search([ - ('product_tmpl_id', '=', self.dashboard.id), - ('attribute_value_ids', '=', self.gps_yes.id), - ('attribute_value_ids', '=', self.color_blue.id) - ]) + blue_dashboard_gps = self.dashboard._get_variant_for_combination(self.dashboard_color_blue + self.dashboard_gps_yes) self.assertEqual(blue_dashboard_gps.id, report_values['lines']['components'][1]['prod_id']) self.assertEqual(report_values['lines']['components'][1]['prod_qty'], 1) component = report_values['lines']['components'][1] @@ -620,16 +637,13 @@ class TestBoM(TestMrpCommon): # Total cost of blue car with GPS: 10 + 700 + 200 = 910 self.assertEqual(report_values['lines']['total'], 910) - red_car_without_gps = self.env['product.product'].search([ - ('product_tmpl_id', '=', self.car.id), - ('attribute_value_ids', '=', self.gps_no.id), - ('attribute_value_ids', '=', self.color_red.id) - ]) + red_car_without_gps = self.car._get_variant_for_combination(self.car_color_red + self.car_gps_no) + report_values = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_car.id, searchQty=1, searchVariant=red_car_without_gps.id) # Same math than before but without GPS self.assertEqual(report_values['lines']['total'], 210) - def test_bom_report_recursive_bom(self): + def test_22_bom_report_recursive_bom(self): """ Test report with recursive BoM and different quantities. BoM 1: product = Finished (units) diff --git a/addons/mrp/views/mrp_bom_views.xml b/addons/mrp/views/mrp_bom_views.xml index c64dbee0ab9..1a23fa44581 100644 --- a/addons/mrp/views/mrp_bom_views.xml +++ b/addons/mrp/views/mrp_bom_views.xml @@ -80,9 +80,9 @@ <field name="product_qty"/> <field name="product_uom_category_id" invisible="1"/> <field name="parent_product_tmpl_id" invisible="1" /> - <field name="valid_product_attribute_value_ids" invisible="1"/> + <field name="possible_bom_product_template_attribute_value_ids" invisible="1"/> <field name="product_uom_id" options="{'no_open':True,'no_create':True}" groups="uom.group_uom"/> - <field name="attribute_value_ids" widget="many2many_tags" domain="[('id', 'in', valid_product_attribute_value_ids)]" options="{'no_create': True}" attrs="{'column_invisible': [('parent.product_id', '!=', False)]}" groups="product.group_product_variant"/> + <field name="bom_product_template_attribute_value_ids" widget="many2many_tags" options="{'no_create': True}" attrs="{'column_invisible': [('parent.product_id', '!=', False)]}" groups="product.group_product_variant"/> <field name="operation_id" groups="mrp.group_mrp_routings" attrs="{'column_invisible': [('parent.type','not in', ('normal', 'phantom'))]}" domain="[('routing_id', '=', parent.routing_id)]" options="{'no_quick_create':True,'no_create_edit':True}"/> </tree> </field> diff --git a/addons/product/data/product_demo.xml b/addons/product/data/product_demo.xml index 229e87b652a..1a09d7798e0 100644 --- a/addons/product/data/product_demo.xml +++ b/addons/product/data/product_demo.xml @@ -185,47 +185,85 @@ </record> <!-- the product template attribute lines have to be defined before creating the variants --> - <record id="product_template_attribute_line_1" model="product.template.attribute.line"> + <record id="product_4_attribute_1_product_template_attribute_line" model="product.template.attribute.line"> <field name="product_tmpl_id" ref="product_product_4_product_template"/> <field name="attribute_id" ref="product_attribute_1"/> <field name="value_ids" eval="[(6, 0, [ref('product.product_attribute_value_1'), ref('product.product_attribute_value_2')])]"/> </record> - <record id="product_template_attribute_line_2" model="product.template.attribute.line"> + <record id="product_4_attribute_2_product_template_attribute_line" model="product.template.attribute.line"> <field name="product_tmpl_id" ref="product_product_4_product_template"/> <field name="attribute_id" ref="product_attribute_2"/> <field name="value_ids" eval="[(6, 0, [ref('product.product_attribute_value_3'), ref('product.product_attribute_value_4')])]"/> </record> + <!-- + Handle automatically created product.template.attribute.value. + Meaning that the combination between the "customizable desk" and the attribute value "black" will be materialized + into a "product.template.attribute.value" with the ref "product.product_4_attribute_1_value_1". + This will allow setting fields like "price_extra" and "exclude_for" + --> + <function model="ir.model.data" name="_update_xmlids"> + <value model="base" eval="[{ + 'xml_id': 'product.product_4_attribute_1_value_1', + 'record': obj().env.ref('product.product_4_attribute_1_product_template_attribute_line').product_template_value_ids[0], + 'noupdate': True, + }, { + 'xml_id': 'product.product_4_attribute_1_value_2', + 'record': obj().env.ref('product.product_4_attribute_1_product_template_attribute_line').product_template_value_ids[1], + 'noupdate': True, + }, { + 'xml_id': 'product.product_4_attribute_2_value_1', + 'record': obj().env.ref('product.product_4_attribute_2_product_template_attribute_line').product_template_value_ids[0], + 'noupdate': True, + }, { + 'xml_id': 'product.product_4_attribute_2_value_2', + 'record': obj().env.ref('product.product_4_attribute_2_product_template_attribute_line').product_template_value_ids[1], + 'noupdate': True, + },]"/> + </function> + + <function model="ir.model.data" name="_update_xmlids"> + <value model="base" eval="[{ + 'xml_id': 'product.product_product_4', + 'record': obj().env.ref('product.product_product_4_product_template')._get_variant_for_combination(obj().env.ref('product.product_4_attribute_1_value_1') + obj().env.ref('product.product_4_attribute_2_value_1')), + 'noupdate': True, + }, { + 'xml_id': 'product.product_product_4b', + 'record': obj().env.ref('product.product_product_4_product_template')._get_variant_for_combination(obj().env.ref('product.product_4_attribute_1_value_1') + obj().env.ref('product.product_4_attribute_2_value_2')), + 'noupdate': True, + }, { + 'xml_id': 'product.product_product_4c', + 'record': obj().env.ref('product.product_product_4_product_template')._get_variant_for_combination(obj().env.ref('product.product_4_attribute_1_value_2') + obj().env.ref('product.product_4_attribute_2_value_1')), + 'noupdate': True, + }, { + 'xml_id': 'product.product_product_4d', + 'record': obj().env.ref('product.product_product_4_product_template')._get_variant_for_combination(obj().env.ref('product.product_4_attribute_1_value_2') + obj().env.ref('product.product_4_attribute_2_value_2')), + 'noupdate': True, + },]"/> + </function> + <record id="product_product_4" model="product.product"> <field name="default_code">FURN_0096</field> <field name="standard_price">500.0</field> <field name="weight">0.01</field> - <field name="attribute_value_ids" eval="[(6,0,[ref('product.product_attribute_value_1'), ref('product.product_attribute_value_3')])]"/> - <field name="product_tmpl_id" ref="product_product_4_product_template"/> <field name="image_1920" type="base64" file="product/static/img/table02.png"/> </record> <record id="product_product_4b" model="product.product"> <field name="default_code">FURN_0097</field> <field name="weight">0.01</field> - <field name="product_tmpl_id" ref="product_product_4_product_template"/> <field name="standard_price">500.0</field> - <field name="attribute_value_ids" eval="[(6,0,[ref('product.product_attribute_value_1'), ref('product.product_attribute_value_4')])]"/> <field name="image_1920" type="base64" file="product/static/img/table04.png"/> </record> <record id="product_product_4c" model="product.product"> <field name="default_code">FURN_0098</field> <field name="weight">0.01</field> <field name="standard_price">500.0</field> - <field name="product_tmpl_id" ref="product_product_4_product_template"/> - <field name="attribute_value_ids" eval="[(6,0,[ref('product.product_attribute_value_2'), ref('product.product_attribute_value_3')])]"/> <field name="image_1920" type="base64" file="product/static/img/table03.png"/> </record> <record id="product_product_4d" model="product.product"> <field name="default_code">DESK0004</field> <field name="weight">0.01</field> - <field name="product_tmpl_id" ref="product_product_4_product_template"/> <field name="standard_price">500.0</field> - <field name="attribute_value_ids" eval="[(6,0,[ref('product.product_attribute_value_2'), ref('product.product_attribute_value_4')])]"/> <field name="image_1920" type="base64" file="product/static/img/table01.png"/> </record> @@ -321,48 +359,50 @@ </record> <!-- the product template attribute lines have to be defined before creating the variants --> - <record id="product_template_attribute_line_4" model="product.template.attribute.line"> + <record id="product_11_attribute_1_product_template_attribute_line" model="product.template.attribute.line"> <field name="product_tmpl_id" ref="product_product_11_product_template"/> <field name="attribute_id" ref="product_attribute_1"/> <field name="value_ids" eval="[(6,0,[ref('product.product_attribute_value_1'), ref('product.product_attribute_value_2')])]"/> </record> + <function model="ir.model.data" name="_update_xmlids"> + <value model="base" eval="[{ + 'xml_id': 'product.product_11_attribute_1_value_1', + 'record': obj().env.ref('product.product_11_attribute_1_product_template_attribute_line').product_template_value_ids[0], + 'noupdate': True, + }, { + 'xml_id': 'product.product_11_attribute_1_value_2', + 'record': obj().env.ref('product.product_11_attribute_1_product_template_attribute_line').product_template_value_ids[1], + 'noupdate': True, + }]"/> + </function> + + <function model="ir.model.data" name="_update_xmlids"> + <value model="base" eval="[{ + 'xml_id': 'product.product_product_11', + 'record': obj().env.ref('product.product_product_11_product_template')._get_variant_for_combination(obj().env.ref('product.product_11_attribute_1_value_1')), + 'noupdate': True, + }, { + 'xml_id': 'product.product_product_11b', + 'record': obj().env.ref('product.product_product_11_product_template')._get_variant_for_combination(obj().env.ref('product.product_11_attribute_1_value_2')), + 'noupdate': True, + },]"/> + </function> + <record id="product_product_11" model="product.product"> <field name="default_code">E-COM12</field> <field name="weight">0.01</field> - <field name="product_tmpl_id" ref="product_product_11_product_template"/> - <field name="attribute_value_ids" eval="[(6, 0, [ref('product.product_attribute_value_1')])]"/> </record> <record id="product_product_11b" model="product.product"> <field name="default_code">E-COM13</field> <field name="weight">0.01</field> - <field name="product_tmpl_id" ref="product_product_11_product_template"/> - <field name="attribute_value_ids" eval="[(6, 0, [ref('product.product_attribute_value_2')])]"/> </record> - <!-- - Handle automatically created product.template.attribute.value. - Meaning that the combination between the "customizable desk" and the attribute value "black" will be materialized - into a "product.template.attribute.value" with the ref "product.product_template_attribute_value_1". - This will allow setting fields like "price_extra" and "exclude_for" - --> - <function model="ir.model.data" name="_update_xmlids"> - <value model="base" eval="[{ - 'xml_id': 'product.product_template_attribute_value_1', - 'record': obj().env.ref('product.product_template_attribute_line_1').product_template_value_ids[1], - 'noupdate': True, - }, { - 'xml_id': 'product.product_template_attribute_value_2', - 'record': obj().env.ref('product.product_template_attribute_line_4').product_template_value_ids[1], - 'noupdate': True, - }]"/> - </function> - - <record id="product_template_attribute_value_1" model="product.template.attribute.value"> + <record id="product.product_4_attribute_1_value_2" model="product.template.attribute.value"> <field name="price_extra">50.40</field> </record> - <record id="product_template_attribute_value_2" model="product.template.attribute.value"> + <record id="product.product_11_attribute_1_value_2" model="product.template.attribute.value"> <field name="price_extra">6.40</field> </record> diff --git a/addons/product/models/product.py b/addons/product/models/product.py index 386520ee160..7a6ba5f1a67 100644 --- a/addons/product/models/product.py +++ b/addons/product/models/product.py @@ -97,9 +97,8 @@ class ProductProduct(models.Model): barcode = fields.Char( 'Barcode', copy=False, help="International Article Number used for product identification.") - attribute_value_ids = fields.Many2many('product.attribute.value', string='Attribute Values') - product_template_attribute_value_ids = fields.Many2many( - 'product.template.attribute.value', string='Template Attribute Values', compute="_compute_product_template_attribute_value_ids") + product_template_attribute_value_ids = fields.Many2many('product.template.attribute.value', relation='product_variant_combination', string="Attribute Values", ondelete='restrict') + combination_indices = fields.Char(compute='_compute_combination_indices', store=True, index=True) is_product_variant = fields.Boolean(compute='_compute_is_product_variant') standard_price = fields.Float( @@ -202,6 +201,14 @@ class ProductProduct(models.Model): for record in self: record.can_image_1024_be_zoomed = record.can_image_variant_1024_be_zoomed if record.image_variant_1920 else record.product_tmpl_id.can_image_1024_be_zoomed + def init(self): + """Ensure there is at most one active variant for each combination. + + There could be no variant for a combination if using dynamic attributes. + """ + self.env.cr.execute("CREATE UNIQUE INDEX IF NOT EXISTS product_product_combination_unique ON %s (product_tmpl_id, combination_indices) WHERE active is true" + % self._table) + _sql_constraints = [ ('barcode_uniq', 'unique(barcode)', "A barcode can only be assigned to one product !"), ] @@ -209,6 +216,11 @@ class ProductProduct(models.Model): def _get_invoice_policy(self): return False + @api.depends('product_template_attribute_value_ids') + def _compute_combination_indices(self): + for product in self: + product.combination_indices = product.product_template_attribute_value_ids._ids2str() + def _compute_is_product_variant(self): for product in self: product.is_product_variant = True @@ -258,7 +270,7 @@ class ProductProduct(models.Model): def _compute_product_price_extra(self): for product in self: - product.price_extra = sum(product.mapped('product_template_attribute_value_ids.price_extra')) + product.price_extra = sum(product.product_template_attribute_value_ids.mapped('price_extra')) @api.depends('list_price', 'price_extra') @api.depends_context('uom') @@ -295,30 +307,6 @@ class ProductProduct(models.Model): else: product.partner_ref = product.display_name - @api.depends('product_tmpl_id', 'attribute_value_ids') - def _compute_product_template_attribute_value_ids(self): - # Fetch and pre-map the values first for performance. It assumes there - # won't be too many values, but there might be a lot of products. - values = self.env['product.template.attribute.value'].search([ - ('product_tmpl_id', 'in', self.mapped('product_tmpl_id').ids), - ('product_attribute_value_id', 'in', self.mapped('attribute_value_ids').ids), - ]) - - values_per_template = {} - for ptav in values: - pt_id = ptav.product_tmpl_id.id - if pt_id not in values_per_template: - values_per_template[pt_id] = {} - values_per_template[pt_id][ptav.product_attribute_value_id.id] = ptav - - for product in self: - product.product_template_attribute_value_ids = self.env['product.template.attribute.value'] - for pav in product.attribute_value_ids: - if product.product_tmpl_id.id not in values_per_template or pav.id not in values_per_template[product.product_tmpl_id.id]: - _logger.warning("A matching product.template.attribute.value was not found for the product.attribute.value #%s on the template #%s" % (pav.id, product.product_tmpl_id.id)) - else: - product.product_template_attribute_value_ids += values_per_template[product.product_tmpl_id.id][pav.id] - def _compute_variant_item_count(self): for product in self: domain = ['|', @@ -326,17 +314,6 @@ class ProductProduct(models.Model): '&', ('product_id', '=', product.id), ('applied_on', '=', '0_product_variant')] product.pricelist_item_count = self.env['product.pricelist.item'].search_count(domain) - @api.constrains('attribute_value_ids') - def _check_attribute_value_ids(self): - for product in self: - attributes = self.env['product.attribute'] - for value in product.attribute_value_ids: - if value.attribute_id in attributes: - raise ValidationError(_('Error! It is not allowed to choose more than one value for a given attribute.')) - if value.attribute_id.create_variant == 'always': - attributes |= value.attribute_id - return True - @api.onchange('uom_id', 'uom_po_id') def _onchange_uom(self): if self.uom_id and self.uom_po_id and self.uom_id.category_id != self.uom_po_id.category_id: @@ -347,20 +324,12 @@ class ProductProduct(models.Model): products = super(ProductProduct, self.with_context(create_product_product=True)).create(vals_list) # `_get_variant_id_for_combination` depends on existing variants self.clear_caches() - self.env['product.template'].invalidate_cache( - fnames=[ - 'product_variant_ids', - 'product_variant_id', - 'product_variant_count' - ], - ids=products.mapped('product_tmpl_id').ids - ) return products def write(self, values): res = super(ProductProduct, self).write(values) - if 'attribute_value_ids' in values: - # `_get_variant_id_for_combination` depends on `attribute_value_ids` + if 'product_template_attribute_value_ids' in values: + # `_get_variant_id_for_combination` depends on `product_template_attribute_value_ids` self.clear_caches() if 'active' in values: # prefetched o2m have to be reloaded (because of active_test) @@ -475,7 +444,7 @@ class ProductProduct(models.Model): # Prefetch the fields used by the `name_get`, so `browse` doesn't fetch other fields # Use `load=False` to not call `name_get` for the `product_tmpl_id` - self.sudo().read(['name', 'default_code', 'product_tmpl_id', 'attribute_value_ids', 'attribute_line_ids'], load=False) + self.sudo().read(['name', 'default_code', 'product_tmpl_id'], load=False) product_template_ids = self.sudo().mapped('product_tmpl_id').ids @@ -491,9 +460,7 @@ class ProductProduct(models.Model): for r in supplier_info: supplier_info_by_template.setdefault(r.product_tmpl_id, []).append(r) for product in self.sudo(): - # display only the attributes with multiple possible values on the template - variable_attributes = product.attribute_line_ids.filtered(lambda l: len(l.value_ids) > 1).mapped('attribute_id') - variant = product.attribute_value_ids._variant_name(variable_attributes) + variant = product.product_template_attribute_value_ids._get_combination_name() name = variant and "%s (%s)" % (product.name, variant) or product.name sellers = [] @@ -699,31 +666,6 @@ class ProductProduct(models.Model): return name - def _has_valid_attributes(self, valid_attributes, valid_values): - """ Check if a product has valid attributes. It is considered valid if: - - it uses ALL valid attributes - - it ONLY uses valid values - We must make sure that all attributes are used to take into account the case where - attributes would be added to the template. - - This method does not check if the combination is possible, it just - checks if it has valid attributes and values. A possible combination - is always valid, but a valid combination is not always possible. - - :param valid_attributes: a recordset of product.attribute - :param valid_values: a recordset of product.attribute.value - :return: True if the attibutes and values are correct, False instead - """ - self.ensure_one() - values = self.attribute_value_ids - attributes = values.mapped('attribute_id') - if attributes != valid_attributes: - return False - for value in values: - if value not in valid_values: - return False - return True - def _is_variant_possible(self, parent_combination=None): """Return whether the variant is possible based on its own combination, and optionally a parent combination. diff --git a/addons/product/models/product_attribute.py b/addons/product/models/product_attribute.py index 50c31ba2a06..409ad8c9cc1 100644 --- a/addons/product/models/product_attribute.py +++ b/addons/product/models/product_attribute.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models, _ +from odoo import api, fields, models, tools, _ from odoo.exceptions import UserError, ValidationError from odoo.osv import expression @@ -36,7 +36,7 @@ class ProductAttribute(models.Model): for pa in self: pa.is_used_on_products = bool(pa.product_tmpl_ids) - @api.depends('attribute_line_ids.product_tmpl_id') + @api.depends('attribute_line_ids.active', 'attribute_line_ids.product_tmpl_id') def _compute_products(self): for pa in self: pa.product_tmpl_ids = pa.attribute_line_ids.product_tmpl_id @@ -116,9 +116,6 @@ class ProductAttributeValue(models.Model): return super(ProductAttributeValue, self).name_get() return [(value.id, "%s: %s" % (value.attribute_id.name, value.name)) for value in self] - def _variant_name(self, variable_attributes): - return ", ".join([v.name for v in self if v.attribute_id in variable_attributes]) - def write(self, values): if 'attribute_id' in values: for pav in self: @@ -159,20 +156,21 @@ class ProductTemplateAttributeLine(models.Model): _description = 'Product Template Attribute Line' _order = 'attribute_id, id' + active = fields.Boolean(default=True) product_tmpl_id = fields.Many2one('product.template', string="Product Template", ondelete='cascade', required=True, index=True) attribute_id = fields.Many2one('product.attribute', string="Attribute", ondelete='restrict', required=True, index=True) value_ids = fields.Many2many('product.attribute.value', string="Values", domain="[('attribute_id', '=', attribute_id)]", - relation='product_attribute_value_product_template_attribute_line_rel') + relation='product_attribute_value_product_template_attribute_line_rel', ondelete='restrict') product_template_value_ids = fields.One2many('product.template.attribute.value', 'attribute_line_id', string="Product Attribute Values") @api.onchange('attribute_id') def _onchange_attribute_id(self): self.value_ids = self.value_ids.filtered(lambda pav: pav.attribute_id == self.attribute_id) - @api.constrains('value_ids', 'attribute_id') + @api.constrains('active', 'value_ids', 'attribute_id') def _check_valid_values(self): for ptal in self: - if not ptal.value_ids: + if ptal.active and not ptal.value_ids: raise ValidationError( _("The attribute <strong>%s</strong> must have at least one value for the product %s.") % (ptal.attribute_id.display_name, ptal.product_tmpl_id.display_name) @@ -186,12 +184,48 @@ class ProductTemplateAttributeLine(models.Model): return True @api.model_create_multi - def create(self, values): - res = super(ProductTemplateAttributeLine, self).create(values) + def create(self, vals_list): + """Override to: + - Activate archived lines having the same configuration (if they exist) + instead of creating new lines. + - Set up related values and related variants. + + Reactivating existing lines allows to re-use existing variants when + possible, keeping their configuration and avoiding duplication. + """ + create_values = [] + activated_lines = self.env['product.template.attribute.line'] + for value in vals_list: + vals = dict(value, active=value.get('active', True)) + # While not ideal for peformance, this search has to be done at each + # step to exclude the lines that might have been activated at a + # previous step. Since `vals_list` will likely be a small list in + # all use cases, this is an acceptable trade-off. + archived_ptal = self.search([ + ('active', '=', False), + ('product_tmpl_id', '=', vals.pop('product_tmpl_id', 0)), + ('attribute_id', '=', vals.pop('attribute_id', 0)), + ], limit=1) + if archived_ptal: + # Write given `vals` in addition of `active` to ensure + # `value_ids` or other fields passed to `create` are saved too, + # but change the context to avoid updating the values and the + # variants until all the expected lines are created/updated. + archived_ptal.with_context(update_product_template_attribute_values=False).write(vals) + activated_lines += archived_ptal + else: + create_values.append(value) + res = activated_lines + super(ProductTemplateAttributeLine, self).create(create_values) res._update_product_template_attribute_values() return res def write(self, values): + """Override to: + - Add constraints to prevent doing changes that are not supported such + as modifying the template or the attribute of existing lines. + - Clean up related values and related variants when archiving or when + updating `value_ids`. + """ if 'product_tmpl_id' in values: for ptal in self: if ptal.product_tmpl_id.id != values['product_tmpl_id']: @@ -207,41 +241,116 @@ class ProductTemplateAttributeLine(models.Model): _("On the product %s you cannot transform the attribute <strong>%s</strong> into the attribute %s.") % (ptal.product_tmpl_id.display_name, ptal.attribute_id.display_name, values['attribute_id']) ) - + # Remove all values while archiving to make sure the line is clean if it + # is ever activated again. + if not values.get('active', True): + values['value_ids'] = [(5, 0, 0)] res = super(ProductTemplateAttributeLine, self).write(values) - self._update_product_template_attribute_values() + if 'active' in values: + self.env['product.template'].invalidate_cache(fnames=['attribute_line_ids']) + # If coming from `create`, no need to update the values and the variants + # before all lines are created. + if self.env.context.get('update_product_template_attribute_values', True): + self._update_product_template_attribute_values() return res + def unlink(self): + """Override to: + - Archive the line if unlink is not possible. + - Clean up related values and related variants. + + Archiving is typically needed when the line has values that can't be + deleted because they are referenced elsewhere (on a variant that can't + be deleted, on a sales order line, ...). + """ + # Try to remove the values first to remove some potentially blocking + # references, which typically works: + # - For single value lines because the values are directly removed from + # the variants. + # - For values that are present on variants that can be deleted. + self.product_template_value_ids._only_active().unlink() + # Keep a reference to the related templates before the deletion. + templates = self.product_tmpl_id + # Now delete or archive the lines. + ptal_to_archive = self.env['product.template.attribute.line'] + for ptal in self: + try: + with self.env.cr.savepoint(), tools.mute_logger('odoo.sql_db'): + super(ProductTemplateAttributeLine, ptal).unlink() + except Exception: + # We catch all kind of exceptions to be sure that the operation + # doesn't fail. + ptal_to_archive += ptal + ptal_to_archive.write({'active': False}) + # For archived lines `_update_product_template_attribute_values` is + # implicitly called during the `write` above, but for products that used + # unlinked lines `_create_variant_ids` has to be called manually. + (templates - ptal_to_archive.product_tmpl_id)._create_variant_ids() + return True + def _update_product_template_attribute_values(self): """Create or unlink `product.template.attribute.value` for each line in `self` based on `value_ids`. + The goal is to delete all values that are not in `value_ids`, to + activate those in `value_ids` that are currently archived, and to create + those in `value_ids` that didn't exist. + This is a trick for the form view and for performance in general, because we don't want to generate in advance all possible values for all templates, but only those that will be selected. """ + ProductTemplateAttributeValue = self.env['product.template.attribute.value'] ptav_to_create = [] - ptav_to_unlink = self.env['product.template.attribute.value'] - + ptav_to_unlink = ProductTemplateAttributeValue for ptal in self: - existing_pav = self.env['product.attribute.value'] + ptav_to_activate = ProductTemplateAttributeValue + remaining_pav = ptal.value_ids for ptav in ptal.product_template_value_ids: - if ptav.product_attribute_value_id not in ptal.value_ids: - # remove values that existed but don't exist anymore - ptav_to_unlink += ptav + if ptav.product_attribute_value_id not in remaining_pav: + # Remove values that existed but don't exist anymore, but + # ignore those that are already archived because if they are + # archived it means they could not be deleted previously. + if ptav.ptav_active: + ptav_to_unlink += ptav else: - existing_pav += ptav.product_attribute_value_id - - for pav in (ptal.value_ids - existing_pav): - # create values that didn't exist yet - ptav_to_create.append({ - 'product_attribute_value_id': pav.id, - 'attribute_line_id': ptal.id - }) - - # unlink and create in batch for performance + # Activate corresponding values that are currently archived. + remaining_pav -= ptav.product_attribute_value_id + if not ptav.ptav_active: + ptav_to_activate += ptav + + for pav in remaining_pav: + # The previous loop searched for archived values that belonged to + # the current line, but if the line was deleted and another line + # was recreated for the same attribute, we need to expand the + # search to those with matching `attribute_id`. + # While not ideal for peformance, this search has to be done at + # each step to exclude the values that might have been activated + # at a previous step. Since `remaining_pav` will likely be a + # small list in all use cases, this is an acceptable trade-off. + ptav = ProductTemplateAttributeValue.search([ + ('ptav_active', '=', False), + ('product_tmpl_id', '=', ptal.product_tmpl_id.id), + ('attribute_id', '=', ptal.attribute_id.id), + ('product_attribute_value_id', '=', pav.id), + ], limit=1) + if ptav: + ptav.write({'ptav_active': True, 'attribute_line_id': ptal.id}) + # If the value was marked for deletion, now keep it. + ptav_to_unlink -= ptav + else: + # create values that didn't exist yet + ptav_to_create.append({ + 'product_attribute_value_id': pav.id, + 'attribute_line_id': ptal.id + }) + # Handle active at each step in case a following line might want to + # re-use a value that was archived at a previous step. + ptav_to_activate.write({'ptav_active': True}) + ptav_to_unlink.write({'ptav_active': False}) ptav_to_unlink.unlink() - self.env['product.template.attribute.value'].create(ptav_to_create) + ProductTemplateAttributeValue.create(ptav_to_create) + self.product_tmpl_id._create_variant_ids() @api.model def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None): @@ -265,14 +374,17 @@ class ProductTemplateAttributeValue(models.Model): _name = "product.template.attribute.value" _description = "Product Template Attribute Value" - _order = 'product_attribute_value_id, id' + _order = 'attribute_line_id, product_attribute_value_id, id' + # Not just `active` because we always want to show the values except in + # specific case, as opposed to `active_test`. + ptav_active = fields.Boolean("Active", default=True) name = fields.Char('Value', related="product_attribute_value_id.name") # defining fields: the product template attribute line and the product attribute value product_attribute_value_id = fields.Many2one( 'product.attribute.value', string='Attribute Value', - required=True, ondelete='restrict', index=True) + required=True, ondelete='cascade', index=True) attribute_line_id = fields.Many2one('product.template.attribute.line', required=True, ondelete='cascade', index=True) # configuration fields: the price_extra and the exclusion rules @@ -293,6 +405,7 @@ class ProductTemplateAttributeValue(models.Model): # related fields: product template and product attribute product_tmpl_id = fields.Many2one('product.template', string="Product Template", related='attribute_line_id.product_tmpl_id', store=True, index=True) 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) _sql_constraints = [ ('attribute_value_unique', 'unique(attribute_line_id, product_attribute_value_id)', "Each value should be defined only once per attribute per product."), @@ -307,7 +420,19 @@ class ProductTemplateAttributeValue(models.Model): (ptav.product_attribute_value_id.display_name, ptav.attribute_id.display_name, ptav.product_tmpl_id.display_name) ) + @api.model_create_multi + def create(self, vals_list): + if any('ptav_product_variant_ids' in v for v in vals_list): + # Force write on this relation from `product.product` to properly + # trigger `_compute_combination_indices`. + raise UserError(_("You cannot update related variants from the values. Please update related values from the variants.")) + return super(ProductTemplateAttributeValue, self).create(vals_list) + def write(self, values): + if 'ptav_product_variant_ids' in values: + # Force write on this relation from `product.product` to properly + # trigger `_compute_combination_indices`. + raise UserError(_("You cannot update related variants from the values. Please update related values from the variants.")) pav_in_values = 'product_attribute_value_id' in values product_in_values = 'product_tmpl_id' in values if pav_in_values or product_in_values: @@ -322,9 +447,40 @@ class ProductTemplateAttributeValue(models.Model): _("You cannot change the product of the value <strong>%s</strong> set on product %s.") % (ptav.display_name, ptav.product_tmpl_id.display_name) ) - return super(ProductTemplateAttributeValue, self).write(values) + def unlink(self): + """Override to: + - Clean up the variants that use any of the values in self: + - Remove the value from the variant if the value belonged to an + attribute line with only one value. + - Unlink or archive all related variants. + - Archive the value if unlink is not possible. + + Archiving is typically needed when the value is referenced elsewhere + (on a variant that can't be deleted, on a sales order line, ...). + """ + # Directly remove the values from the variants for lines that had single + # value (counting also the values that are archived). + single_values = self.filtered(lambda ptav: len(ptav.attribute_line_id.product_template_value_ids) == 1) + for ptav in single_values: + ptav.ptav_product_variant_ids.write({'product_template_attribute_value_ids': [(3, ptav.id, 0)]}) + # Try to remove the variants before deleting to potentially remove some + # blocking references. + self.ptav_product_variant_ids._unlink_or_archive() + # Now delete or archive the values. + ptav_to_archive = self.env['product.template.attribute.value'] + for ptav in self: + try: + with self.env.cr.savepoint(), tools.mute_logger('odoo.sql_db'): + super(ProductTemplateAttributeLine, ptav).unlink() + except Exception: + # We catch all kind of exceptions to be sure that the operation + # doesn't fail. + ptav_to_archive += ptav + ptav_to_archive.write({'ptav_active': False}) + return True + def name_get(self): """Override because in general the name of the value is confusing if it is displayed without the name of the corresponding attribute. @@ -332,13 +488,49 @@ class ProductTemplateAttributeValue(models.Model): """ return [(value.id, "%s: %s" % (value.attribute_id.name, value.name)) for value in self] + def _only_active(self): + return self.filtered(lambda ptav: ptav.ptav_active) + def _without_no_variant_attributes(self): return self.filtered(lambda ptav: ptav.attribute_id.create_variant != 'no_variant') + def _ids2str(self): + return ','.join([str(i) for i in sorted(self.ids)]) + + def _get_combination_name(self): + """Exclude values from single value lines or from no_variant attributes.""" + return ", ".join([ptav.name for ptav in self._without_no_variant_attributes()._filter_single_value_lines()]) + + def _filter_single_value_lines(self): + """Return `self` with values from single value lines filtered out + depending on the active state of all the values in `self`. + + If any value in `self` is archived, archived values are also taken into + account when checking for single values. + This allows to display the correct name for archived variants. + + If all values in `self` are active, only active values are taken into + account when checking for single values. + This allows to display the correct name for active combinations. + """ + only_active = all(ptav.ptav_active for ptav in self) + return self.filtered(lambda ptav: not ptav._is_from_single_value_line(only_active)) + + def _is_from_single_value_line(self, only_active=True): + """Return whether `self` is from a single value line, counting also + archived values if `only_active` is False. + """ + self.ensure_one() + all_values = self.attribute_line_id.product_template_value_ids + if only_active: + all_values = all_values._only_active() + return len(all_values) == 1 + class ProductTemplateAttributeExclusion(models.Model): _name = "product.template.attribute.exclusion" _description = 'Product Template Attribute Exclusion' + _order = 'product_tmpl_id, value_ids, id' product_template_attribute_value_id = fields.Many2one( 'product.template.attribute.value', string="Attribute Value", ondelete='cascade', index=True) @@ -346,4 +538,4 @@ class ProductTemplateAttributeExclusion(models.Model): 'product.template', string='Product Template', ondelete='cascade', required=True, index=True) 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)]") + string='Attribute Values', domain="[('product_tmpl_id', '=', product_tmpl_id), ('ptav_active', '=', True)]") diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index 087291c1d97..27eeb1ec3a9 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -3,11 +3,11 @@ import itertools import logging -_logger = logging.getLogger(__name__) from odoo import api, fields, models, tools, _ from odoo.exceptions import ValidationError, RedirectWarning, UserError -from odoo.osv import expression + +_logger = logging.getLogger(__name__) class ProductTemplate(models.Model): @@ -125,18 +125,7 @@ class ProductTemplate(models.Model): attribute_line_ids = fields.One2many('product.template.attribute.line', 'product_tmpl_id', 'Product Attributes', copy=True) valid_product_template_attribute_line_ids = fields.Many2many('product.template.attribute.line', - compute="_compute_valid_attributes", string='Valid Product Attribute Lines', help="Technical compute") - valid_product_attribute_value_ids = fields.Many2many('product.attribute.value', - compute="_compute_valid_attributes", string='Valid Product Attribute Values', help="Technical compute") - valid_product_attribute_ids = fields.Many2many('product.attribute', - compute="_compute_valid_attributes", string='Valid Product Attributes', help="Technical compute") - # wnva = without no_variant attributes - valid_product_template_attribute_line_wnva_ids = fields.Many2many('product.template.attribute.line', - compute="_compute_valid_attributes", string='Valid Product Attribute Lines Without No Variant Attributes', help="Technical compute") - valid_product_attribute_value_wnva_ids = fields.Many2many('product.attribute.value', - compute="_compute_valid_attributes", string='Valid Product Attribute Values Without No Variant Attributes', help="Technical compute") - valid_product_attribute_wnva_ids = fields.Many2many('product.attribute', - compute="_compute_valid_attributes", string='Valid Product Attributes Without No Variant Attributes', help="Technical compute") + compute="_compute_valid_product_template_attribute_line_ids", string='Valid Product Attribute Lines', help="Technical compute") product_variant_ids = fields.One2many('product.product', 'product_tmpl_id', 'Products', required=True) # performance: product_variant_id provides prefetching on the first product variant only @@ -371,7 +360,7 @@ class ProductTemplate(models.Model): ''' Store the initial standard price in order to be able to retrieve the cost of a product template for a given date''' templates = super(ProductTemplate, self).create(vals_list) if "create_product_product" not in self._context: - templates.create_variant_ids() + templates._create_variant_ids() # This is needed to set given values to first variant after creation for template, vals in zip(templates, vals_list): @@ -397,7 +386,7 @@ class ProductTemplate(models.Model): def write(self, vals): res = super(ProductTemplate, self).write(vals) if 'attribute_line_ids' in vals or vals.get('active'): - self.create_variant_ids() + self._create_variant_ids() if 'active' in vals and not vals.get('active'): self.with_context(active_test=False).mapped('product_variant_ids').write({'active': vals.get('active')}) if 'image_1920' in vals: @@ -525,7 +514,7 @@ class ProductTemplate(models.Model): return prices - def create_variant_ids(self): + def _create_variant_ids(self): self.flush() Product = self.env["product.product"] @@ -533,13 +522,27 @@ class ProductTemplate(models.Model): variants_to_activate = Product variants_to_unlink = Product - for tmpl_id in self.with_context(active_test=False): + for tmpl_id in self: + lines_without_no_variants = tmpl_id.valid_product_template_attribute_line_ids._without_no_variant_attributes() + + all_variants = tmpl_id.with_context(active_test=False).product_variant_ids.sorted('active') + + current_variants_to_create = [] + current_variants_to_activate = Product + # adding an attribute with only one value should not recreate product # write this attribute on every product to make sure we don't lose them - variant_alone = tmpl_id.valid_product_template_attribute_line_ids.filtered(lambda line: line.attribute_id.create_variant == 'always' and len(line.value_ids) == 1).mapped('value_ids') - for value_id in variant_alone: - updated_products = tmpl_id.product_variant_ids.filtered(lambda product: value_id.attribute_id not in product.mapped('attribute_value_ids.attribute_id')) - updated_products.write({'attribute_value_ids': [(4, value_id.id)]}) + single_value_lines = lines_without_no_variants.filtered(lambda ptal: len(ptal.product_template_value_ids._only_active()) == 1) + if single_value_lines: + for variant in all_variants: + combination = variant.product_template_attribute_value_ids | single_value_lines.product_template_value_ids._only_active() + # Do not add single value if the resulting combination would + # be invalid anyway. + if ( + len(combination) == len(lines_without_no_variants) and + combination.attribute_line_id == lines_without_no_variants + ): + variant.product_template_attribute_value_ids = combination # Determine which product variants need to be created based on the attribute # configuration. If any attribute is set to generate variants dynamically, skip the @@ -547,24 +550,24 @@ class ProductTemplate(models.Model): # Technical note: if there is no attribute, a variant is still created because # 'not any([])' and 'set([]) not in set([])' are True. if not tmpl_id.has_dynamic_attributes(): - # Iterator containing all possible `product.attribute.value` combination + # Iterator containing all possible `product.template.attribute.value` combination # The iterator is used to avoid MemoryError in case of a huge number of combination. - all_variants = itertools.product(*( - line.value_ids.ids for line in tmpl_id.valid_product_template_attribute_line_wnva_ids - )) - # Set containing existing `product.attribute.value` combination + all_combinations = itertools.product(*[ + ptal.product_template_value_ids._only_active() for ptal in lines_without_no_variants + ]) + # Set containing existing `product.template.attribute.value` combination existing_variants = { - frozenset(variant.attribute_value_ids.ids) - for variant in tmpl_id.product_variant_ids + variant.product_template_attribute_value_ids: variant for variant in all_variants } # For each possible variant, create if it doesn't exist yet. - current_variants_to_create = [] - for value_ids in all_variants: - value_ids = frozenset(value_ids) - if value_ids not in existing_variants: + for combination_tuple in all_combinations: + combination = self.env['product.template.attribute.value'].concat(*combination_tuple) + if combination in existing_variants: + current_variants_to_activate += existing_variants[combination] + else: current_variants_to_create.append({ 'product_tmpl_id': tmpl_id.id, - 'attribute_value_ids': [(6, 0, list(value_ids))], + 'product_template_attribute_value_ids': [(6, 0, combination.ids)], 'active': tmpl_id.active, }) if len(current_variants_to_create) > 1000: @@ -572,23 +575,10 @@ class ProductTemplate(models.Model): 'The number of variants to generate is too high. ' 'You should either not generate variants for each combination or generate them on demand from the sales order. ' 'To do so, open the form view of attributes and change the mode of *Create Variants*.')) - variants_to_create += current_variants_to_create - # Check existing variants if any needs to be activated or unlinked. - # - if the product is not active and has valid attributes and attribute values, it - # should be activated - # - if the product does not have valid attributes or attribute values, it should be - # deleted - valid_value_ids = tmpl_id.valid_product_attribute_value_wnva_ids - valid_attribute_ids = tmpl_id.valid_product_attribute_wnva_ids - seen_attributes = set(p.attribute_value_ids for p in tmpl_id.product_variant_ids if p.active) - for product_id in tmpl_id.product_variant_ids: - if product_id._has_valid_attributes(valid_attribute_ids, valid_value_ids): - if not product_id.active and product_id.attribute_value_ids not in seen_attributes: - variants_to_activate += product_id - seen_attributes.add(product_id.attribute_value_ids) - else: - variants_to_unlink += product_id + variants_to_activate += current_variants_to_activate + + variants_to_unlink += all_variants - current_variants_to_activate if variants_to_activate: variants_to_activate.write({'active': True}) @@ -612,10 +602,10 @@ class ProductTemplate(models.Model): :rtype: bool """ self.ensure_one() - return any(a.create_variant == 'dynamic' for a in self.valid_product_attribute_ids) + return any(a.create_variant == 'dynamic' for a in self.valid_product_template_attribute_line_ids.attribute_id) - @api.depends('attribute_line_ids', 'attribute_line_ids.value_ids') - def _compute_valid_attributes(self): + @api.depends('attribute_line_ids.value_ids') + def _compute_valid_product_template_attribute_line_ids(self): """A product template attribute line is considered valid if it has at least one possible value. @@ -623,28 +613,9 @@ class ProductTemplate(models.Model): not appear on the configurator itself (unless they have an is_custom value to input), indeed single value attributes can be used to filter products among others based on that attribute/value. - - A product attribute value is considered valid for a template if it is - defined on a product template attribute line. - - A product attribute is considered valid for a template if it - has at least one possible value set on the template. - - For what is considered an archived variant, see `_has_valid_attributes`. """ - # prefetch - self.mapped('attribute_line_ids.value_ids.id') - self.mapped('attribute_line_ids.attribute_id.create_variant') - for record in self: record.valid_product_template_attribute_line_ids = record.attribute_line_ids.filtered(lambda ptal: ptal.value_ids) - record.valid_product_template_attribute_line_wnva_ids = record.valid_product_template_attribute_line_ids._without_no_variant_attributes() - - record.valid_product_attribute_value_ids = record.valid_product_template_attribute_line_ids.mapped('value_ids') - record.valid_product_attribute_value_wnva_ids = record.valid_product_template_attribute_line_wnva_ids.mapped('value_ids') - - record.valid_product_attribute_ids = record.valid_product_template_attribute_line_ids.mapped('attribute_id') - record.valid_product_attribute_wnva_ids = record.valid_product_template_attribute_line_wnva_ids.mapped('attribute_id') def _get_possible_variants(self, parent_combination=None): """Return the existing variants that are possible. @@ -808,11 +779,11 @@ class ProductTemplate(models.Model): # configuration of attributes on the template return False - if self.valid_product_attribute_ids != combination.mapped('attribute_id'): + if self.valid_product_template_attribute_line_ids != combination.attribute_line_id: # combination has different attributes than the ones configured on the template return False - if self.valid_product_attribute_value_ids < combination.mapped('product_attribute_value_id'): + if self.valid_product_template_attribute_line_ids.product_template_value_ids._only_active() < combination: # combination has different values than the ones configured on the template return False @@ -860,10 +831,8 @@ class ProductTemplate(models.Model): :rtype: recordset `product.product` """ self.ensure_one() - filtered_combination = combination._without_no_variant_attributes() - attribute_values = filtered_combination.mapped('product_attribute_value_id') - return self.env['product.product'].browse(self._get_variant_id_for_combination(attribute_values)) + return self.env['product.product'].browse(self._get_variant_id_for_combination(filtered_combination)) def _create_product_variant(self, combination, log_warning=False): """ Create if necessary and possible and return the product variant @@ -871,6 +840,8 @@ class ProductTemplate(models.Model): It is possible to create only if the template has dynamic attributes and the combination itself is possible. + If we are in this case and the variant already exists but it is + archived, it is activated instead of being created again. :param combination: the combination for which to get or create variant. The combination must contain all necessary attributes, including @@ -891,6 +862,8 @@ class ProductTemplate(models.Model): product_variant = self._get_variant_for_combination(combination) if product_variant: + if not product_variant.active and self.has_dynamic_attributes() and self._is_combination_possible(combination): + product_variant.active = True return product_variant if not self.has_dynamic_attributes(): @@ -903,33 +876,23 @@ class ProductTemplate(models.Model): _logger.warning('The user #%s tried to create an invalid variant for the product %s.' % (self.env.user.id, self.id)) return Product - attribute_values = combination.mapped('product_attribute_value_id')._without_no_variant_attributes() - return Product.sudo().create({ 'product_tmpl_id': self.id, - 'attribute_value_ids': [(6, 0, attribute_values.ids)] + 'product_template_attribute_value_ids': [(6, 0, combination._without_no_variant_attributes().ids)] }) - @tools.ormcache('self.id', 'attribute_values') - def _get_variant_id_for_combination(self, attribute_values): + @tools.ormcache('self.id', 'frozenset(filtered_combination.ids)') + def _get_variant_id_for_combination(self, filtered_combination): """See `_get_variant_for_combination`. This method returns an ID - so it can be cached.""" + so it can be cached. + + Use sudo because the same result should be cached for all users. + """ self.ensure_one() - # If there are a lot of variants on this template, it is much faster to - # build a query than using the existing o2m. - domain = [('product_tmpl_id', '=', self.id)] - for pav in attribute_values: - domain = expression.AND([[('attribute_value_ids', 'in', pav.id)], domain]) - - res = self.env['product.product'].with_context(active_test=False).search(domain, order='active DESC') - - # The domain above is checking for the `product.attribute.value`, but we - # need to make sure it's the same `product.template.attribute.value`. - # Also there should theorically be only 0 or 1 but an existing database - # might not be consistent so we need to make sure to take max 1. - return res.filtered( - lambda v: v.attribute_value_ids == attribute_values - )[:1].id + return self.env['product.product'].sudo().with_context(active_test=False).search([ + ('product_tmpl_id', '=', self.id), + ('combination_indices', '=', filtered_combination._ids2str()) + ], order='active DESC', limit=1).id @tools.ormcache('self.id') def _get_first_possible_variant_id(self): @@ -993,7 +956,7 @@ class ProductTemplate(models.Model): yield(combination) else: # we have remaining attribute lines to consider - for ptav in reversed(attribute_lines[0].product_template_value_ids): + for ptav in reversed(attribute_lines[0].product_template_value_ids._only_active()): ptal_stack.append(attribute_lines[1:]) combination_stack.append(combination + ptav) diff --git a/addons/product/report/product_pricelist_templates.xml b/addons/product/report/product_pricelist_templates.xml index f5f59aeff23..0f7d54db714 100644 --- a/addons/product/report/product_pricelist_templates.xml +++ b/addons/product/report/product_pricelist_templates.xml @@ -45,7 +45,7 @@ [<span t-esc="product.code"/>] </t> <span t-esc="product.name"/> - <span t-foreach="product.attribute_value_ids" t-as="attribute_value"> + <span t-foreach="product.product_template_attribute_value_ids" t-as="attribute_value"> <span t-if="attribute_value_first">-</span> <span t-if="not attribute_value_last" t-esc="attribute_value.name+','"/> <span t-else="" t-esc="attribute_value.name"/> diff --git a/addons/product/tests/common.py b/addons/product/tests/common.py index cc91604e129..3e3f11c3f6c 100644 --- a/addons/product/tests/common.py +++ b/addons/product/tests/common.py @@ -64,9 +64,9 @@ class TestProductCommon(common.SavepointCase): 'uom_po_id': cls.uom_unit.id}) cls.prod_att_1 = cls.env['product.attribute'].create({'name': 'Color'}) - cls.prod_attr1_v1 = cls.env['product.attribute.value'].create({'name': 'red', 'attribute_id': cls.prod_att_1.id}) - cls.prod_attr1_v2 = cls.env['product.attribute.value'].create({'name': 'blue', 'attribute_id': cls.prod_att_1.id}) - cls.prod_attr1_v3 = cls.env['product.attribute.value'].create({'name': 'green', 'attribute_id': cls.prod_att_1.id}) + cls.prod_attr1_v1 = cls.env['product.attribute.value'].create({'name': 'red', 'attribute_id': cls.prod_att_1.id, 'sequence': 1}) + cls.prod_attr1_v2 = cls.env['product.attribute.value'].create({'name': 'blue', 'attribute_id': cls.prod_att_1.id, 'sequence': 2}) + cls.prod_attr1_v3 = cls.env['product.attribute.value'].create({'name': 'green', 'attribute_id': cls.prod_att_1.id, 'sequence': 3}) cls.product_7_template = cls.env['product.template'].create({ 'name': 'Sofa', @@ -74,12 +74,17 @@ class TestProductCommon(common.SavepointCase): 'uom_po_id': cls.uom_unit.id, 'attribute_line_ids': [(0, 0, { 'attribute_id': cls.prod_att_1.id, - 'value_ids': [(6, 0, [cls.prod_attr1_v1.id, cls.prod_attr1_v2.id])] + 'value_ids': [(6, 0, [cls.prod_attr1_v1.id, cls.prod_attr1_v2.id, cls.prod_attr1_v3.id])] })] }) - cls.product_7_1 = cls.product_7_template.product_variant_ids.filtered(lambda p: p.attribute_value_ids == cls.prod_attr1_v1) - cls.product_7_2 = cls.product_7_template.product_variant_ids.filtered(lambda p: p.attribute_value_ids == cls.prod_attr1_v2) - cls.product_7_3 = cls.product_7_template.product_variant_ids.filtered(lambda p: p.attribute_value_ids == cls.prod_attr1_v3) + + cls.product_7_attr1_v1 = cls.product_7_template.attribute_line_ids[0].product_template_value_ids[0] + cls.product_7_attr1_v2 = cls.product_7_template.attribute_line_ids[0].product_template_value_ids[1] + cls.product_7_attr1_v3 = cls.product_7_template.attribute_line_ids[0].product_template_value_ids[2] + + cls.product_7_1 = cls.product_7_template._get_variant_for_combination(cls.product_7_attr1_v1) + cls.product_7_2 = cls.product_7_template._get_variant_for_combination(cls.product_7_attr1_v2) + cls.product_7_3 = cls.product_7_template._get_variant_for_combination(cls.product_7_attr1_v3) cls.product_8 = Product.create({ 'name': 'House', diff --git a/addons/product/tests/test_product_attribute_value_config.py b/addons/product/tests/test_product_attribute_value_config.py index 6f4c4b27213..2cd783d0f6a 100644 --- a/addons/product/tests/test_product_attribute_value_config.py +++ b/addons/product/tests/test_product_attribute_value_config.py @@ -23,16 +23,12 @@ class TestProductAttributeValueSetup(TransactionCase): self._add_ram_attribute() self._add_hdd_attribute() - self.computer.create_variant_ids() - self.computer_case = self.env['product.template'].create({ 'name': 'Super Computer Case' }) self._add_size_attribute() - self.computer_case.create_variant_ids() - def _add_ssd_attribute(self): self.ssd_attribute = self.env['product.attribute'].create({'name': 'Memory', 'sequence': 1}) self.ssd_256 = self.env['product.attribute.value'].create({ @@ -206,15 +202,7 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup): # under defined variant combination = computer_ssd_256 + computer_ram_8 variant = self.computer._get_variant_for_combination(combination) - self.assertEqual(len(variant), 0) - - # also test _has_valid_attributes (case ok): - valid_value_ids = self.computer.valid_product_attribute_value_wnva_ids - valid_attribute_ids = self.computer.valid_product_attribute_wnva_ids - self.assertTrue(ok_variant._has_valid_attributes(valid_attribute_ids, valid_value_ids)) - - # also test _has_valid_attributes (case not ok): - self.assertFalse(ok_variant._has_valid_attributes(valid_attribute_ids, valid_value_ids - self.hdd_1)) + self.assertFalse(variant) def test_product_filtered_exclude_for(self): """ @@ -289,8 +277,6 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup): 'value_ids': [(6, 0, [color_red.id, color_green.id])], }) - mouse.create_variant_ids() - mouse_color_red = self._get_product_template_attribute_value(color_red, mouse) mouse_color_green = self._get_product_template_attribute_value(color_green, mouse) @@ -317,15 +303,15 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup): # CASE: if multiple variants exist for the same combination and at least # one of them is not archived, the combination is possible - values = self.ssd_256 + self.ram_8 + self.hdd_1 + combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1 self.env['product.product'].create({ 'product_tmpl_id': self.computer.id, - 'attribute_value_ids': [(6, 0, values.ids)], + 'product_template_attribute_value_ids': [(6, 0, combination.ids)], 'active': False, }) self.env['product.product'].create({ 'product_tmpl_id': self.computer.id, - 'attribute_value_ids': [(6, 0, values.ids)], + 'product_template_attribute_value_ids': [(6, 0, combination.ids)], 'active': True, }) self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1)) @@ -487,26 +473,29 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup): def test_clear_caches(self): """The goal of this test is to make sure the cache is invalidated when it should be.""" - attribute_values = self.ssd_256 + self.ram_8 + self.hdd_1 + computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) + computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) + computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) + combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1 - # CASE: initial result of _get_variant_id_for_combination - variant_id = self.computer._get_variant_id_for_combination(attribute_values) - self.assertTrue(variant_id) + # CASE: initial result of _get_variant_for_combination + variant = self.computer._get_variant_for_combination(combination) + self.assertTrue(variant) # CASE: clear_caches in product.product unlink - self.env['product.product'].browse(variant_id).unlink() - self.assertFalse(self.computer._get_variant_id_for_combination(attribute_values)) + variant.unlink() + self.assertFalse(self.computer._get_variant_for_combination(combination)) # CASE: clear_caches in product.product create variant = self.env['product.product'].create({ 'product_tmpl_id': self.computer.id, - 'attribute_value_ids': [(6, 0, attribute_values.ids)], + 'product_template_attribute_value_ids': [(6, 0, combination.ids)], }) - self.assertEqual(variant.id, self.computer._get_variant_id_for_combination(attribute_values)) + self.assertEqual(variant, self.computer._get_variant_for_combination(combination)) # CASE: clear_caches in product.product write - variant.attribute_value_ids = False - self.assertFalse(self.computer._get_variant_id_for_combination(attribute_values)) + variant.product_template_attribute_value_ids = False + self.assertFalse(self.computer._get_variant_id_for_combination(combination)) def test_constraints(self): """The goal of this test is to make sure constraints are correct.""" diff --git a/addons/product/tests/test_variants.py b/addons/product/tests/test_variants.py index bdf89523f6c..a7d66176982 100644 --- a/addons/product/tests/test_variants.py +++ b/addons/product/tests/test_variants.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 +from collections import OrderedDict import io from PIL import Image @@ -9,6 +10,7 @@ from . import common from odoo.exceptions import UserError from odoo.tests.common import TransactionCase + class TestVariantsSearch(TransactionCase): def setUp(self): @@ -86,7 +88,7 @@ class TestVariants(common.TestProductCommon): # produced variants: one variant, because mono value self.assertEqual(len(test_template.product_variant_ids), 1) - self.assertEqual(test_template.product_variant_ids.attribute_value_ids, self.size_attr_value_s) + self.assertEqual(test_template.product_variant_ids.product_template_attribute_value_ids.product_attribute_value_id, self.size_attr_value_s) def test_variants_creation_mono_double(self): test_template = self.env['product.template'].create({ @@ -104,7 +106,7 @@ class TestVariants(common.TestProductCommon): # produced variants: one variant, because only 1 combination is possible self.assertEqual(len(test_template.product_variant_ids), 1) - self.assertEqual(test_template.product_variant_ids.attribute_value_ids, self.size_attr_value_s + self.prod_attr1_v2) + self.assertEqual(test_template.product_variant_ids.product_template_attribute_value_ids.product_attribute_value_id, self.size_attr_value_s + self.prod_attr1_v2) def test_variants_creation_mono_multi(self): test_template = self.env['product.template'].create({ @@ -119,14 +121,17 @@ class TestVariants(common.TestProductCommon): 'value_ids': [(4, self.size_attr_value_s.id), (4, self.size_attr_value_m.id)], })] }) + sofa_attr1_v2 = test_template.attribute_line_ids[0].product_template_value_ids[0] + sofa_size_s = test_template.attribute_line_ids[1].product_template_value_ids[0] + sofa_size_m = test_template.attribute_line_ids[1].product_template_value_ids[1] # produced variants: two variants, simple matrix self.assertEqual(len(test_template.product_variant_ids), 2) - for value in self.size_attr_value_s + self.size_attr_value_m: + for ptav in sofa_size_s + sofa_size_m: products = self.env['product.product'].search([ ('product_tmpl_id', '=', test_template.id), - ('attribute_value_ids', 'in', value.id), - ('attribute_value_ids', 'in', self.prod_attr1_v2.id) + ('product_template_attribute_value_ids', 'in', ptav.id), + ('product_template_attribute_value_ids', 'in', sofa_attr1_v2.id) ]) self.assertEqual(len(products), 1) @@ -144,14 +149,20 @@ class TestVariants(common.TestProductCommon): })] }) + sofa_attr1_v1 = test_template.attribute_line_ids[0].product_template_value_ids[0] + sofa_attr1_v2 = test_template.attribute_line_ids[0].product_template_value_ids[1] + sofa_size_s = test_template.attribute_line_ids[1].product_template_value_ids[0] + sofa_size_m = test_template.attribute_line_ids[1].product_template_value_ids[1] + sofa_size_l = test_template.attribute_line_ids[1].product_template_value_ids[2] + # produced variants: value matrix : 2x3 values self.assertEqual(len(test_template.product_variant_ids), 6) - for value_1 in self.prod_attr1_v1 + self.prod_attr1_v2: - for value_2 in self.size_attr_value_m + self.size_attr_value_m + self.size_attr_value_l: + for value_1 in sofa_attr1_v1 + sofa_attr1_v2: + for value_2 in sofa_size_s + sofa_size_m + sofa_size_l: products = self.env['product.product'].search([ ('product_tmpl_id', '=', test_template.id), - ('attribute_value_ids', 'in', value_1.id), - ('attribute_value_ids', 'in', value_2.id) + ('product_template_attribute_value_ids', 'in', value_1.id), + ('product_template_attribute_value_ids', 'in', value_2.id) ]) self.assertEqual(len(products), 1) @@ -201,7 +212,7 @@ class TestVariantsNoCreate(common.TestProductCommon): })], }) self.assertEqual(len(template.product_variant_ids), 1) - self.assertFalse(template.product_variant_ids.attribute_value_ids) + self.assertFalse(template.product_variant_ids.product_template_attribute_value_ids) def test_update_mono(self): """ modify a product with a 'nocreate' attribute with a single value """ @@ -219,7 +230,7 @@ class TestVariantsNoCreate(common.TestProductCommon): })], }) self.assertEqual(len(template.product_variant_ids), 1) - self.assertFalse(template.product_variant_ids.attribute_value_ids) + self.assertFalse(template.product_variant_ids.product_template_attribute_value_ids) def test_create_multi(self): """ create a product with a 'nocreate' attribute with several values """ @@ -233,7 +244,7 @@ class TestVariantsNoCreate(common.TestProductCommon): })], }) self.assertEqual(len(template.product_variant_ids), 1) - self.assertFalse(template.product_variant_ids.attribute_value_ids) + self.assertFalse(template.product_variant_ids.product_template_attribute_value_ids) def test_update_multi(self): """ modify a product with a 'nocreate' attribute with several values """ @@ -251,7 +262,7 @@ class TestVariantsNoCreate(common.TestProductCommon): })], }) self.assertEqual(len(template.product_variant_ids), 1) - self.assertFalse(template.product_variant_ids.attribute_value_ids) + self.assertFalse(template.product_variant_ids.product_template_attribute_value_ids) def test_create_mixed_mono(self): """ create a product with regular and 'nocreate' attributes """ @@ -272,7 +283,7 @@ class TestVariantsNoCreate(common.TestProductCommon): }) self.assertEqual(len(template.product_variant_ids), 2) self.assertEqual( - {variant.attribute_value_ids for variant in template.product_variant_ids}, + {variant.product_template_attribute_value_ids.product_attribute_value_id for variant in template.product_variant_ids}, {self.prod_attr1_v1, self.prod_attr1_v2}, ) @@ -299,7 +310,7 @@ class TestVariantsNoCreate(common.TestProductCommon): }) self.assertEqual(len(template.product_variant_ids), 2) self.assertEqual( - {variant.attribute_value_ids for variant in template.product_variant_ids}, + {variant.product_template_attribute_value_ids.product_attribute_value_id for variant in template.product_variant_ids}, {self.prod_attr1_v1, self.prod_attr1_v2}, ) @@ -322,7 +333,7 @@ class TestVariantsNoCreate(common.TestProductCommon): }) self.assertEqual(len(template.product_variant_ids), 2) self.assertEqual( - {variant.attribute_value_ids for variant in template.product_variant_ids}, + {variant.product_template_attribute_value_ids.product_attribute_value_id for variant in template.product_variant_ids}, {self.prod_attr1_v1, self.prod_attr1_v2}, ) @@ -349,7 +360,7 @@ class TestVariantsNoCreate(common.TestProductCommon): }) self.assertEqual(len(template.product_variant_ids), 2) self.assertEqual( - {variant.attribute_value_ids for variant in template.product_variant_ids}, + {variant.product_template_attribute_value_ids.product_attribute_value_id for variant in template.product_variant_ids}, {self.prod_attr1_v1, self.prod_attr1_v2}, ) @@ -373,7 +384,7 @@ class TestVariantsNoCreate(common.TestProductCommon): })] self.assertEqual(len(template.product_variant_ids), 1) # no_variant attribute should not appear on the variant - self.assertNotIn(self.size_S, template.product_variant_ids.attribute_value_ids) + self.assertNotIn(self.size_S, template.product_variant_ids.product_template_attribute_value_ids.product_attribute_value_id) class TestVariantsManyAttributes(common.TestAttributesCommon): @@ -474,7 +485,7 @@ class TestVariantsImages(common.TestProductCommon): def setUp(self): res = super(TestVariantsImages, self).setUp() - self.colors = {'red': '#FF0000', 'green': '#00FF00', 'blue': '#0000FF'} + self.colors = OrderedDict([('none', ''), ('red', '#FF0000'), ('green', '#00FF00'), ('blue', '#0000FF')]) self.images = {} product_attribute = self.env['product.attribute'].create({'name': 'Color'}) @@ -486,27 +497,26 @@ class TestVariantsImages(common.TestProductCommon): color_values = self.env['product.attribute.value'].create([{ 'name': color, 'attribute_id': product_attribute.id, - } for color in self.colors]) + 'sequence': i, + } for i, color in enumerate(self.colors)]) - self.env['product.template.attribute.line'].create({ + ptal = self.env['product.template.attribute.line'].create({ 'attribute_id': product_attribute.id, 'product_tmpl_id': self.template.id, 'value_ids': [(6, 0, color_values.ids)], }) - for color, color_value in zip(self.colors, color_values): + for color_value in ptal.product_template_value_ids[1:]: f = io.BytesIO() - Image.new('RGB', (800, 500), self.colors[color]).save(f, 'PNG') + Image.new('RGB', (800, 500), self.colors[color_value.name]).save(f, 'PNG') f.seek(0) - self.images.update({color: base64.b64encode(f.read())}) + self.images.update({color_value.name: base64.b64encode(f.read())}) - self.env['product.product'].create({ - 'image_variant_1920': self.images[color], - 'attribute_value_ids': [(6, 0, [color_value.id])], - 'product_tmpl_id': self.template.id, + self.template._get_variant_for_combination(color_value).write({ + 'image_variant_1920': self.images[color_value.name], }) - # the first one has no image, no color - self.variants = self.template.product_variant_ids.sorted('id') + # the first one has no image + self.variants = self.template.product_variant_ids return res @@ -546,282 +556,374 @@ class TestVariantsImages(common.TestProductCommon): class TestVariantsArchive(common.TestProductCommon): """Once a variant is used on orders/invoices, etc, they can't be unlinked. - As a result, updating attributes on a product template would simply archive the variants instead. - We make sure that at each update, we have the correct number of active and inactive records. - - In these tests, we use the commands sent by the JS framework to the ORM when using the interface - (even though using 3 commands would simplify the code compared to 2 commands). - - Note that: we don't overly test the ids of archived / modified variants. - In some cases _create_product_variants reuse existing variants and in other create new ones, - in a way that is not very predictable (without reading the code). - We do not enshrine the current behaviour in the tests (yet) as it could change with only moderate functional impact. - Consequently, if it is intentional the number of archived variants could change, - in that case update the test; it is there to make sure that a fix is not going to break anything silently. - The most important is to keep correct numbers of active variants. + As a result, updating attributes on a product template would simply + archive the variants instead. We make sure that at each update, we have + the correct active and inactive records. + + In these tests, we use the commands sent by the JS framework to the ORM + when using the interface. """ def setUp(self): res = super(TestVariantsArchive, self).setUp() - attribute_values = { - 'color': ['white', 'black'], - 'size': ['s', 'm'], - } - - self.attributes = {} - for attr, attr_values in attribute_values.items(): - attribute = self.env['product.attribute'].create({'name': attr}) - values = [self.env['product.attribute.value'].create({'name': name, 'attribute_id': attribute.id}) - for name in attr_values] - self.attributes.update({attr: {'a': attribute, 'v': values}}) - - attribute_line_ids = [(0, 0, { - 'attribute_id': attr['a'].id, - 'value_ids': [(6, 0, [v.id for v in attr['v']])], - }) for attr in self.attributes.values()] + self.pa_color = self.env['product.attribute'].create({'name': "color", 'sequence': 1}) + color_values = self.env['product.attribute.value'].create([{ + 'name': n, + 'sequence': i, + 'attribute_id': self.pa_color.id, + } for i, n in enumerate(['white', 'black'])]) + self.pav_color_white = color_values[0] + self.pav_color_black = color_values[1] + + self.pa_size = self.env['product.attribute'].create({'name': "size", 'sequence': 2}) + size_values = self.env['product.attribute.value'].create([{ + 'name': n, + 'sequence': i, + 'attribute_id': self.pa_size.id, + } for i, n in enumerate(['s', 'm'])]) + self.pav_size_s = size_values[0] + self.pav_size_m = size_values[1] self.template = self.env['product.template'].create({ 'name': 'consume product', - 'attribute_line_ids': attribute_line_ids, + 'attribute_line_ids': self._get_add_all_attributes_command(), }) - + self._update_color_vars(self.template.attribute_line_ids[0]) + self._update_size_vars(self.template.attribute_line_ids[1]) return res - def test_update_variant_unlink(self): + def test_01_update_variant_unlink(self): """Variants are not used anywhere, so removing an attribute line would unlink the variants and create new ones. Nothing too fancy here. """ - lines = self.template.attribute_line_ids - self.assertEqual(len(self.template.product_variant_ids), 4) - # we keep lines[0], remove and copy lines[1] - line_copy_command = {'attribute_id': lines[1].attribute_id.id, 'value_ids': [(6, False, lines[1].value_ids.mapped('id'))]} - self.template.write({'attribute_line_ids': [(4, lines[0].id), (2, lines[1].id)]}) - self.assertEqual(len(self.template.product_variant_ids), len(lines[0].value_ids), - "Since we only kept line[0], we should have as many variants as it has values") - archived_products = self.env['product.product'].search( - [('active', '=', False), ('product_tmpl_id', '=', self.template.id)]) - self.assertFalse(archived_products, - "Since they are used nowhere, there should not be any archived variant.") - # we re-add the line we just removed, so we should get new variants - self.template.write({ - 'attribute_line_ids': [ - (4, lines[0].id), - (0, 'virtual', line_copy_command), - ]}) - self.assertEqual(len(self.template.product_variant_ids), 4) - - def test_update_variant_archive_1_value(self): - """We do the same operations on the template as in test_update_variant_unlink, + variants_2x2 = self.template.product_variant_ids + self._assert_2color_x_2size() + + # Remove the size line, corresponding variants will be removed too since + # they are used nowhere. Since we only kept color, we should have as many + # variants as it has values. + self._remove_ptal_size() + self._assert_2color_x_0size() + archived_variants = self._get_archived_variants() + self.assertFalse(archived_variants) + + # We re-add the line we just removed, so we should get new variants. + self._add_ptal_size_s_m() + self._assert_2color_x_2size() + self.assertFalse(self.template.product_variant_ids & variants_2x2) + + def test_02_update_variant_archive_1_value(self): + """We do the same operations on the template as in the previous test, except we simulate that the variants can't be unlinked. - It follows that variants should be archived instead, so the results should all be different from previous test. - In this test we have a line that have only one possible value: - this is handled differently than the case where we have more than one, - since in principle it does not add new variants. - """ - # we create a new template with a line with only one attribute. - # Since this does not create any additional variants, the behaviour is a little bit different: - # it tries to add the new attribute to existing variants if possible - attribute_line_ids = [(0, 0, { - 'attribute_id': attr['a'].id, - 'value_ids': [(6, 0, [v.id for v in (attr['v'] if attr['a'].display_name == 'color' else attr['v'][:1])])], - }) for attr in self.attributes.values()] - template = self.env['product.template'].create({ - 'name': 'consume product', - 'attribute_line_ids': attribute_line_ids, - }) + It follows that variants should be archived instead, so the results + should all be different from previous test. + + In this test we have a line that has only one possible value: + this is handled differently than the case where we have more than + one value, since it does not add new variants. + """ + self._remove_ptal_size() + self._add_ptal_size_s() # create a patch to make as if one variant was undeletable # (e.g. present in a field with ondelete=restrict) Product = self.env['product.product'] - no_remove = template.product_variant_ids[0].id + def unlink(self): - if self.id == no_remove: - raise Exception('just') - else: - return super(Product.__class__, self).unlink() + raise Exception('just') Product._patch_method('unlink', unlink) - variants_ids_history = [] - - self.assertEqual(len(template.product_variant_ids), 2) - variants_ids_history.append(template.product_variant_ids.ids) - - lines = template.attribute_line_ids - # we keep lines[0], remove and copy lines[1], which is the one with only one possible value - line_copy_command = {'attribute_id': lines[1].attribute_id.id, - 'value_ids': [(6, False, lines[1].value_ids.mapped('id'))]} - template.write({'attribute_line_ids': [(4, lines[0].id), (2, lines[1].id)]}) - self.assertEqual(len(lines[0].value_ids), 2) - self.assertEqual(len(template.product_variant_ids), len(lines[0].value_ids), - "Since we only kept line[0], we should have as many variants as it has values") - variants_ids_history.append(template.product_variant_ids.ids) - - archived_products_1 = self.env['product.product'].search( - [('active', '=', False), ('product_tmpl_id', '=', template.id)]) - self.assertEqual(len(archived_products_1), 1, - "One variant should have been unlinked, not the other.") - # we re-add the line we just removed, so we should get new variants - template.write({ - 'attribute_line_ids': [ - (4, lines[0].id), - (0, 'virtual', line_copy_command), - ]}) - self.assertEqual(len(template.product_variant_ids), 2) - variants_ids_history.append(template.product_variant_ids.ids) - - archived_products_2 = self.env['product.product'].search( - [('active', '=', False), ('product_tmpl_id', '=', template.id)]) - self.assertEqual(len(archived_products_2), 1, - "We did not reactivate this variant.") - - # tests on ids, see the doc_string - self.assertEqual(archived_products_1, archived_products_2) - self.assertEqual(len(set(variants_ids_history[0] + variants_ids_history[1])), 4, - "After the first update, we have new active variants.") - self.assertEqual(len(set(variants_ids_history[1] + variants_ids_history[2])), 2, - "After the second update, we kept the same active variants.") + variants_2x1 = self.template.product_variant_ids + self._assert_2color_x_1size() + archived_variants = self._get_archived_variants() + self.assertFalse(archived_variants) + + # Remove the size line, which is the one with only one possible value. + # Variants should be kept, just the single value removed from them. + self._remove_ptal_size() + self.assertEqual(variants_2x1, self.template.product_variant_ids) + self._assert_2color_x_0size() + archived_variants = self._get_archived_variants() + self.assertFalse(archived_variants) + + # Add the line just removed, so it is added back to the variants. + self._add_ptal_size_s() + self.assertEqual(variants_2x1, self.template.product_variant_ids) + self._assert_2color_x_1size() + archived_variants = self._get_archived_variants() + self.assertFalse(archived_variants) Product._revert_method('unlink') - def test_update_variant_archive_2_value(self): - """We do the same operations on the template as in test_update_variant_unlink, + def test_02_update_variant_archive_2_value(self): + """We do the same operations on the template as in the previous tests, except we simulate that the variants can't be unlinked. - It follows that variants should be archived instead, so the results should all be different from previous test. + + It follows that variants should be archived instead, so the results + should all be different from previous test. """ Product = self.env['product.product'] + def unlink(slef): raise Exception('just') Product._patch_method('unlink', unlink) - variants_ids_history = [] - - self.assertEqual(len(self.template.product_variant_ids), 4) - variants_ids_history.append(self.template.product_variant_ids.ids) - - lines = self.template.attribute_line_ids - # we keep lines[0], remove and copy lines[1] - line_copy_command = {'attribute_id': lines[1].attribute_id.id, - 'value_ids': [(6, False, lines[1].value_ids.mapped('id'))]} - line_second_copy_command = {'attribute_id': lines[1].attribute_id.id, - 'value_ids': [(6, False, lines[1].value_ids[:1].mapped('id'))]} - self.template.write({'attribute_line_ids': [(4, lines[0].id), (2, lines[1].id)]}) - self.assertEqual(len(self.template.product_variant_ids), len(lines[0].value_ids), - "Since we only kept line[0], we should have as many variants as it has values") - variants_ids_history.append(self.template.product_variant_ids.ids) - - archived_products = [] - archived_products.append(self.env['product.product'].search( - [('active', '=', False), ('product_tmpl_id', '=', self.template.id)])) - self.assertEqual(len(archived_products[0]), 4, - "Since they can't be unlinked, all variants should be archived.") - # we re-add the line we just removed, so we should get new variants - self.template.write({ - 'attribute_line_ids': [ - (4, lines[0].id), - (0, 'virtual', line_copy_command), - ]}) - self.assertEqual(len(self.template.product_variant_ids), 4) - variants_ids_history.append(self.template.product_variant_ids.ids) - - archived_products.append(self.env['product.product'].search( - [('active', '=', False), ('product_tmpl_id', '=', self.template.id)])) - self.assertEqual(len(archived_products[1]), 2, - "Since they can't be unlinked, all previous variants should be archived.") - - # we redo the whole remove/readd to check - lines = self.template.attribute_line_ids - self.template.write({'attribute_line_ids': [(4, lines[0].id), (2, lines[1].id)]}) - self.assertEqual(len(self.template.product_variant_ids), 2) - variants_ids_history.append(self.template.product_variant_ids.ids) - archived_products.append(self.env['product.product'].search( - [('active', '=', False), ('product_tmpl_id', '=', self.template.id)])) - self.template.write({ - 'attribute_line_ids': [ - (4, lines[0].id), - (0, 'virtual', line_copy_command), - ]}) - self.assertEqual(len(self.template.product_variant_ids), 4) - variants_ids_history.append(self.template.product_variant_ids.ids) - archived_products.append(self.env['product.product'].search( - [('active', '=', False), ('product_tmpl_id', '=', self.template.id)])) - self.assertEqual(len(archived_products[3]), 2) - - # now we test when we try to add the value back on existing products - lines = self.template.attribute_line_ids - self.template.write({'attribute_line_ids': [(4, lines[0].id), (2, lines[1].id)]}) - self.assertEqual(len(self.template.product_variant_ids), 2) - variants_ids_history.append(self.template.product_variant_ids.ids) - archived_products.append(self.env['product.product'].search( - [('active', '=', False), ('product_tmpl_id', '=', self.template.id)])) - self.assertEqual(len(archived_products[4]), 4) - # this time we only add one of the two attributes we've been removing - self.template.write({ - 'attribute_line_ids': [ - (4, lines[0].id), - (0, 'virtual', line_second_copy_command), - ]}) - self.assertEqual(len(self.template.product_variant_ids), 2) - variants_ids_history.append(self.template.product_variant_ids.ids) - archived_products.append(self.env['product.product'].search( - [('active', '=', False), ('product_tmpl_id', '=', self.template.id)])) - self.assertEqual(len(archived_products[5]), 4) - Product._revert_method('unlink') + variants_2x2 = self.template.product_variant_ids + self._assert_2color_x_2size() + archived_variants = self._get_archived_variants() + self.assertFalse(archived_variants) + + # CASE remove one attribute line (going from 2*2 to 2*1) + # Since they can't be unlinked, existing variants should be archived. + self._remove_ptal_size() + variants_2x0 = self.template.product_variant_ids + self._assert_2color_x_0size() + archived_variants = self._get_archived_variants() + self.assertEqual(archived_variants, variants_2x2) + self._assert_2color_x_2size(archived_variants) + + # Add the line just removed, so get back the previous variants. + # Since they can't be unlinked, existing variants should be archived. + self._add_ptal_size_s_m() + self.assertEqual(self.template.product_variant_ids, variants_2x2) + self._assert_2color_x_2size() + archived_variants = self._get_archived_variants() + self.assertEqual(archived_variants, variants_2x0) + self._assert_2color_x_0size(archived_variants) + + # we redo the whole remove/read to check + self._remove_ptal_size() + self.assertEqual(self.template.product_variant_ids, variants_2x0) + self._assert_2color_x_0size() + archived_variants = self._get_archived_variants() + self.assertEqual(archived_variants, variants_2x2) + self._assert_2color_x_2size(archived_variants) + + self._add_ptal_size_s_m() + self.assertEqual(self.template.product_variant_ids, variants_2x2) + self._assert_2color_x_2size() + archived_variants = self._get_archived_variants() + self.assertEqual(archived_variants, variants_2x0) + self._assert_2color_x_0size(archived_variants) + + self._remove_ptal_size() + self.assertEqual(self.template.product_variant_ids, variants_2x0) + self._assert_2color_x_0size() + archived_variants = self._get_archived_variants() + self.assertEqual(archived_variants, variants_2x2) + self._assert_2color_x_2size(archived_variants) + + # This time we only add one of the two attributes we've been removing. + # This is a single value line, so the value is simply added to existing + # variants. + self._add_ptal_size_s() + self.assertEqual(self.template.product_variant_ids, variants_2x0) + self._assert_2color_x_1size() + self.assertEqual(archived_variants, variants_2x2) + self._assert_2color_x_2size(archived_variants) - def test_update_variant_archive_3_value(self): - """In this one, we have unique values for one lines, and one line with two values. - We first remove the unique line to populate archived variants, then remove the two values line. - Finally, we re-add athe two lines, bringing us back to the first situation: - we should not have any more than two variants (rest should be archived) - """ - attribute_line_ids = [(0, 0, { - 'attribute_id': attr['a'].id, - 'value_ids': [(6, 0, [v.id for v in (attr['v'] if attr['a'].display_name == 'color' else attr['v'][:1])])], - }) for attr in self.attributes.values()] + Product._revert_method('unlink') - template = self.env['product.template'].create({ - 'name': 'consume product', - 'attribute_line_ids': attribute_line_ids, - }) + def test_03_update_variant_archive_3_value(self): + self._remove_ptal_size() + self._add_ptal_size_s() Product = self.env['product.product'] + def unlink(slef): raise Exception('just') Product._patch_method('unlink', unlink) - variants_ids_history = [] - self.assertEqual(len(template.product_variant_ids), 2) - variants_ids_history.append(template.product_variant_ids.ids) - - lines = template.attribute_line_ids + self._assert_2color_x_1size() + archived_variants = self._get_archived_variants() + self.assertFalse(archived_variants) + variants_2x1 = self.template.product_variant_ids + + # CASE: remove single value line, no variant change + self._remove_ptal_size() + self.assertEqual(self.template.product_variant_ids, variants_2x1) + self._assert_2color_x_0size() + archived_variants = self._get_archived_variants() + self.assertFalse(archived_variants) + + # CASE: empty combination, this generates a new variant + self.template.write({'attribute_line_ids': [(2, self.ptal_color.id)]}) + self._assert_0color_x_0size() + archived_variants = self._get_archived_variants() + self.assertEqual(archived_variants, variants_2x1) + self._assert_2color_x_0size(archived_variants) # single value are removed + variant_0x0 = self.template.product_variant_ids + + # CASE: add single value on empty + self._add_ptal_size_s() + self.assertEqual(self.template.product_variant_ids, variant_0x0) + self._assert_0color_x_1size() + archived_variants = self._get_archived_variants() + self.assertEqual(archived_variants, variants_2x1) + self._assert_2color_x_0size(archived_variants) # single value are removed + + # CASE: empty again + self._remove_ptal_size() + self.assertEqual(self.template.product_variant_ids, variant_0x0) + self._assert_0color_x_0size() + archived_variants = self._get_archived_variants() + self.assertEqual(archived_variants, variants_2x1) + self._assert_2color_x_0size(archived_variants) # single value are removed + + # CASE: re-add everything + self.template.write({ + 'attribute_line_ids': self._get_add_all_attributes_command(), + }) + self._update_color_vars(self.template.attribute_line_ids[0]) + self._update_size_vars(self.template.attribute_line_ids[1]) + self._assert_2color_x_2size() + archived_variants = self._get_archived_variants() + self.assertEqual(archived_variants, variants_2x1 + variant_0x0) - template.write({'attribute_line_ids': [(4, lines[0].id), (2, lines[1].id)]}) - self.assertEqual(len(template.product_variant_ids), len(lines[0].value_ids), - "Since we only kept line[0], we should have as many variants as it has values") - variants_ids_history.append(template.product_variant_ids.ids) + Product._revert_method('unlink') - archived_products = [] - archived_products.append(self.env['product.product'].search( - [('active', '=', False), ('product_tmpl_id', '=', template.id)])) - self.assertEqual(len(archived_products[0]), 2, - "Since they can't be unlinked, all variants should be archived.") + def test_04_from_to_single_values(self): + Product = self.env['product.product'] - template.write({'attribute_line_ids': [(2, lines[0].id)]}) - self.assertEqual(len(template.product_variant_ids), 1) - variants_ids_history.append(template.product_variant_ids.ids) + def unlink(slef): + raise Exception('just') + Product._patch_method('unlink', unlink) - archived_products.append(self.env['product.product'].search( - [('active', '=', False), ('product_tmpl_id', '=', template.id)])) - self.assertEqual(len(archived_products[1]), 4, - "Since they can't be unlinked, all previous variants should be archived.") + # CASE: remove one value, line becoming single value + variants_2x2 = self.template.product_variant_ids + self.ptal_size.write({'value_ids': [(3, self.pav_size_m.id)]}) + self._assert_2color_x_1size() + self.assertEqual(self.template.product_variant_ids, variants_2x2[0] + variants_2x2[2]) + archived_variants = self._get_archived_variants() + self._assert_2color_x_1size(archived_variants, ptav=self.ptav_size_m) + self.assertEqual(archived_variants, variants_2x2[1] + variants_2x2[3]) + + # CASE: add back the value + self.ptal_size.write({'value_ids': [(4, self.pav_size_m.id)]}) + self._assert_2color_x_2size() + self.assertEqual(self.template.product_variant_ids, variants_2x2) + archived_variants = self._get_archived_variants() + self.assertFalse(archived_variants) + + # CASE: remove one value, line becoming single value, and then remove + # the remaining value + self.ptal_size.write({'value_ids': [(3, self.pav_size_m.id)]}) + self._remove_ptal_size() + self._assert_2color_x_0size() + self.assertFalse(self.template.product_variant_ids & variants_2x2) + archived_variants = self._get_archived_variants() + self._assert_2color_x_2size(archived_variants) + self.assertEqual(archived_variants, variants_2x2) + variants_2x0 = self.template.product_variant_ids + + # CASE: add back the values + self._add_ptal_size_s_m() + self._assert_2color_x_2size() + self.assertEqual(self.template.product_variant_ids, variants_2x2) + archived_variants = self._get_archived_variants() + self._assert_2color_x_0size(archived_variants) + self.assertEqual(archived_variants, variants_2x0) - # we re-add everything: - template.write({'attribute_line_ids': attribute_line_ids}) + Product._revert_method('unlink') - archived_products.append(self.env['product.product'].search( - [('active', '=', False), ('product_tmpl_id', '=', template.id)])) - self.assertEqual(len(template.product_variant_ids), 2) - self.assertEqual(len(archived_products[2]), 3, - "We should have re-activated one variant only.") + def _update_color_vars(self, ptal): + self.ptal_color = ptal + self.assertEqual(self.ptal_color.attribute_id, self.pa_color) + self.ptav_color_white = self.ptal_color.product_template_value_ids[0] + self.assertEqual(self.ptav_color_white.product_attribute_value_id, self.pav_color_white) + self.ptav_color_black = self.ptal_color.product_template_value_ids[1] + self.assertEqual(self.ptav_color_black.product_attribute_value_id, self.pav_color_black) + + def _update_size_vars(self, ptal): + self.ptal_size = ptal + self.assertEqual(self.ptal_size.attribute_id, self.pa_size) + self.ptav_size_s = self.ptal_size.product_template_value_ids[0] + self.assertEqual(self.ptav_size_s.product_attribute_value_id, self.pav_size_s) + if len(self.ptal_size.product_template_value_ids) > 1: + self.ptav_size_m = self.ptal_size.product_template_value_ids[1] + self.assertEqual(self.ptav_size_m.product_attribute_value_id, self.pav_size_m) + + def _get_add_all_attributes_command(self): + return [(0, 0, { + 'attribute_id': pa.id, + 'value_ids': [(6, 0, pa.value_ids.ids)], + }) for pa in self.pa_color + self.pa_size] + + def _get_archived_variants(self): + # Change context to also get archived values when reading them from the + # variants. + return self.env['product.product'].with_context(active_test=False).search([ + ('active', '=', False), + ('product_tmpl_id', '=', self.template.id) + ]) + + def _remove_ptal_size(self): + self.template.write({'attribute_line_ids': [(2, self.ptal_size.id)]}) + + def _add_ptal_size_s_m(self): + self.template.write({ + 'attribute_line_ids': [(0, 0, { + 'attribute_id': self.pa_size.id, + 'value_ids': [(6, 0, (self.pav_size_s + self.pav_size_m).ids)], + })], + }) + self._update_size_vars(self.template.attribute_line_ids[-1]) - Product._revert_method('unlink') + def _add_ptal_size_s(self): + self.template.write({ + 'attribute_line_ids': [(0, 0, { + 'attribute_id': self.pa_size.id, + 'value_ids': [(6, 0, self.pav_size_s.ids)], + })], + }) + self._update_size_vars(self.template.attribute_line_ids[-1]) + + def _get_combinations_names(self, combinations): + return ' | '.join([','.join(c.mapped('name')) for c in combinations]) + + def _assert_required_combinations(self, variants, required_values): + actual_values = [v.product_template_attribute_value_ids for v in variants] + self.assertEqual(set(required_values), set(actual_values), + "\nRequired: %s\nActual: %s" % (self._get_combinations_names(required_values), self._get_combinations_names(actual_values))) + + def _assert_2color_x_2size(self, variants=None): + """Assert the full matrix 2 color x 2 size""" + variants = variants or self.template.product_variant_ids + self.assertEqual(len(variants), 4) + self._assert_required_combinations(variants, required_values=[ + self.ptav_color_white + self.ptav_size_s, + self.ptav_color_white + self.ptav_size_m, + self.ptav_color_black + self.ptav_size_s, + self.ptav_color_black + self.ptav_size_m, + ]) + + def _assert_2color_x_1size(self, variants=None, ptav=None): + """Assert the matrix 2 color x 1 size""" + variants = variants or self.template.product_variant_ids + self.assertEqual(len(variants), 2) + self._assert_required_combinations(variants, required_values=[ + self.ptav_color_white + (ptav or self.ptav_size_s), + self.ptav_color_black + (ptav or self.ptav_size_s), + ]) + + def _assert_2color_x_0size(self, variants=None): + """Assert the matrix 2 color x no size""" + variants = variants or self.template.product_variant_ids + self.assertEqual(len(variants), 2) + self._assert_required_combinations(variants, required_values=[ + self.ptav_color_white, + self.ptav_color_black, + ]) + + def _assert_0color_x_1size(self, variants=None): + """Assert the matrix no color x 1 size""" + variants = variants or self.template.product_variant_ids + self.assertEqual(len(variants), 1) + self.assertEqual(variants[0].product_template_attribute_value_ids, self.ptav_size_s) + + def _assert_0color_x_0size(self, variants=None): + """Assert the matrix no color x no size""" + variants = variants or self.template.product_variant_ids + self.assertEqual(len(variants), 1) + self.assertFalse(variants[0].product_template_attribute_value_ids) diff --git a/addons/product/views/product_attribute_views.xml b/addons/product/views/product_attribute_views.xml index 3832b95e38e..01a4f6b0dcb 100644 --- a/addons/product/views/product_attribute_views.xml +++ b/addons/product/views/product_attribute_views.xml @@ -103,6 +103,7 @@ <tree string="Attributes" create="0" delete="0"> <field name="attribute_id"/> <field name="name"/> + <field name="ptav_active" optional="hide"/> <field name="price_extra"/> </tree> </field> @@ -114,20 +115,33 @@ <field name="type">form</field> <field name="arch" type="xml"> <form string="Product Attribute" create="0" delete="0"> - <group> - <field name="name" /> - <field name="price_extra" /> - <field name="exclude_for" widget="one2many" mode="tree"> - <tree editable="top"> - <field name="product_tmpl_id" /> - <field name="value_ids" widget="many2many_tags" options="{'no_create': true}" /> - </tree> - </field> - </group> + <sheet> + <group> + <field name="ptav_active" readonly="1" attrs="{'invisible': [('ptav_active', '=', True)]}"/> + <field name="name"/> + <field name="price_extra" /> + <field name="exclude_for" widget="one2many" mode="tree"> + <tree editable="bottom"> + <field name="product_tmpl_id" /> + <field name="value_ids" widget="many2many_tags" options="{'no_create': true}" /> + </tree> + </field> + </group> + </sheet> </form> </field> </record> + <record id="product_template_attribute_value_view_search" model="ir.ui.view"> + <field name="model">product.template.attribute.value</field> + <field name="arch" type="xml"> + <search> + <filter string="Active" name="active" domain="[('ptav_active', '=', True)]"/> + <filter string="Inactive" name="inactive" domain="[('ptav_active', '=', False)]"/> + </search> + </field> + </record> + <record id="product_attribute_value_action" model="ir.actions.act_window"> <field name="name">Product Variant Values</field> <field name="res_model">product.template.attribute.value</field> @@ -138,7 +152,8 @@ (0, 0, {'view_mode': 'tree', 'view_id': ref('product.product_template_attribute_value_view_tree')}), (0, 0, {'view_mode': 'form', 'view_id': ref('product.product_template_attribute_value_view_form')})]" /> <field name="context">{ - 'default_product_tmpl_id': active_id + 'default_product_tmpl_id': active_id, + 'search_default_active': 1, }</field> </record> diff --git a/addons/product/views/product_views.xml b/addons/product/views/product_views.xml index fcd295e328b..525424c79c6 100644 --- a/addons/product/views/product_views.xml +++ b/addons/product/views/product_views.xml @@ -184,7 +184,7 @@ <field name="name" string="Product" filter_domain="['|', '|', ('default_code', 'ilike', self), ('name', 'ilike', self), ('barcode', 'ilike', self)]"/> </field> <field name="attribute_line_ids" position="replace"> - <field name="attribute_value_ids" groups="product.group_product_variant"/> + <field name="product_template_attribute_value_ids" groups="product.group_product_variant"/> <field name="product_tmpl_id" string="Product Template"/> </field> </field> @@ -222,7 +222,7 @@ <div class="oe_title"> <label class="oe_edit_only" for="name" string="Product Name"/> <h1><field name="name" readonly="1" placeholder="e.g. Odoo Enterprise Subscription"/></h1> - <field name="attribute_value_ids" widget="many2many_tags" readonly="1"/> + <field name="product_template_attribute_value_ids" widget="many2many_tags" readonly="1"/> <p> <span>All general settings about this product are managed on</span> <button name="open_product_template" type="object" string="the product template." class="oe_link oe_link_product pl-0 ml-1 mb-1"/> @@ -300,7 +300,7 @@ <field name="default_code" optional="show"/> <field name="barcode" optional="hide"/> <field name="name"/> - <field name="attribute_value_ids" widget="many2many_tags" groups="product.group_product_variant"/> + <field name="product_template_attribute_value_ids" widget="many2many_tags" groups="product.group_product_variant"/> <field name="company_id" groups="base.group_multi_company" optional="hide"/> <field name="lst_price" optional="show" string="Sales Price"/> <field name="standard_price" optional="show"/> @@ -342,7 +342,7 @@ <field name="product_tmpl_id" class="oe_inline" readonly="1" invisible="1" attrs="{'required': [('id', '!=', False)]}"/> </field> <xpath expr="//div[hasclass('oe_title')]" position="inside"> - <field name="attribute_value_ids" widget="many2many_tags" readonly="1" + <field name="product_template_attribute_value_ids" widget="many2many_tags" readonly="1" groups="product.group_product_variant"/> </xpath> </field> @@ -369,7 +369,7 @@ <small t-if="record.default_code.value">[<field name="default_code"/>]</small> </strong> <div class="o_kanban_tags_section"> - <field name="attribute_value_ids" groups="product.group_product_variant"/> + <field name="product_template_attribute_value_ids" groups="product.group_product_variant"/> </div> <ul> <li><strong>Price: <field name="lst_price"></field></strong></li> diff --git a/addons/product_matrix/data/product_matrix_demo.xml b/addons/product_matrix/data/product_matrix_demo.xml index c388edcb252..eb6cbff993a 100644 --- a/addons/product_matrix/data/product_matrix_demo.xml +++ b/addons/product_matrix/data/product_matrix_demo.xml @@ -130,6 +130,4 @@ <field name="attribute_id" ref="product.product_attribute_2"/> <field name="value_ids" eval="[(6, 0, [ref('product_attribute_value_color_1'), ref('product_attribute_value_color_2'), ref('product_attribute_value_color_3'), ref('product_attribute_value_color_4')])]"/> </record> - - <function model="product.template" name="create_variant_ids" eval="([ref('product_matrix.matrix_product_template_shirt')])"/> </odoo> diff --git a/addons/product_matrix/models/product_template.py b/addons/product_matrix/models/product_template.py index bafc834f47c..f3b2875ed5b 100644 --- a/addons/product_matrix/models/product_template.py +++ b/addons/product_matrix/models/product_template.py @@ -16,8 +16,8 @@ class ProductTemplate(models.Model): attribute_lines = self.valid_product_template_attribute_line_ids Attrib = self.env['product.template.attribute.value'] - first_line_attributes = attribute_lines[0].product_template_value_ids - attribute_ids_by_line = [line.product_template_value_ids.ids for line in attribute_lines] + first_line_attributes = attribute_lines[0].product_template_value_ids._only_active() + attribute_ids_by_line = [line.product_template_value_ids._only_active().ids for line in attribute_lines] header = [{"name": self.display_name}] + [ attr._grid_header_cell( diff --git a/addons/purchase_product_matrix/models/purchase.py b/addons/purchase_product_matrix/models/purchase.py index a766b567baf..3e9e27f9ed2 100644 --- a/addons/purchase_product_matrix/models/purchase.py +++ b/addons/purchase_product_matrix/models/purchase.py @@ -142,7 +142,7 @@ class PurchaseOrderLine(models.Model): product_template_id = fields.Many2one('product.template', string='Product Template', related="product_id.product_tmpl_id", domain=[('purchase_ok', '=', True)]) is_configurable_product = fields.Boolean('Is the product configurable?', related="product_template_id.has_configurable_attributes") product_template_attribute_value_ids = fields.Many2many(related='product_id.product_template_attribute_value_ids', readonly=True) - product_no_variant_attribute_value_ids = fields.Many2many('product.template.attribute.value', string='Product attribute values that do not create variants') + product_no_variant_attribute_value_ids = fields.Many2many('product.template.attribute.value', string='Product attribute values that do not create variants', ondelete='restrict') def _get_product_purchase_description(self, product): name = super(PurchaseOrderLine, self)._get_product_purchase_description(product) diff --git a/addons/sale/data/sale_demo.xml b/addons/sale/data/sale_demo.xml index aab4fdc83d6..42b7ce650a8 100644 --- a/addons/sale/data/sale_demo.xml +++ b/addons/sale/data/sale_demo.xml @@ -796,52 +796,52 @@ Thanks!</field> <field name="sequence">3</field> </record> - <record id="product.product_template_attribute_line_1" model="product.template.attribute.line"> + <record id="product.product_4_attribute_1_product_template_attribute_line" model="product.template.attribute.line"> <field name="value_ids" eval="[(4,ref('product_attribute_value_7'))]"/> </record> - <record id="product_product_4e" model="product.product"> - <field name="default_code">DESK0005</field> - <field name="weight">0.01</field> - <field name="product_tmpl_id" ref="product.product_product_4_product_template"/> - <field name="attribute_value_ids" eval="[(6,0,[ref('product_attribute_value_7'), ref('product.product_attribute_value_3')])]"/> - </record> - - <record id="product_product_4f" model="product.product"> - <field name="default_code">DESK0006</field> - <field name="weight">0.01</field> - <field name="product_tmpl_id" ref="product.product_product_4_product_template"/> - <field name="attribute_value_ids" eval="[(6,0,[ref('product_attribute_value_7'), ref('product.product_attribute_value_4')])]"/> - </record> - <!-- Handle automatically created product.template.attribute.value. - Check "product.product_template_attribute_value_1" for more information about this + Check "product.product_4_attribute_1_value_2" for more information about this --> <function model="ir.model.data" name="_update_xmlids"> <value model="base" eval="[{ - 'xml_id': 'sale.product_template_attribute_value_3', - 'record': obj().env.ref('product.product_template_attribute_line_1').product_template_value_ids[0], + 'xml_id': 'sale.product_4_attribute_1_value_3', + 'record': obj().env.ref('product.product_4_attribute_1_product_template_attribute_line').product_template_value_ids[2], 'noupdate': True, - }, { - 'xml_id': 'sale.product_template_attribute_value_4', - 'record': obj().env.ref('product.product_template_attribute_line_4').product_template_value_ids[0], + }]"/> + </function> + + <function model="ir.model.data" name="_update_xmlids"> + <value model="base" eval="[{ + 'xml_id': 'sale.product_product_4e', + 'record': obj().env.ref('product.product_product_4_product_template')._get_variant_for_combination(obj().env.ref('sale.product_4_attribute_1_value_3') + obj().env.ref('product.product_4_attribute_2_value_1')), 'noupdate': True, }, { - 'xml_id': 'sale.product_template_attribute_value_5', - 'record': obj().env.ref('product.product_template_attribute_line_2').product_template_value_ids[1], + 'xml_id': 'sale.product_product_4f', + 'record': obj().env.ref('product.product_product_4_product_template')._get_variant_for_combination(obj().env.ref('sale.product_4_attribute_1_value_3') + obj().env.ref('product.product_4_attribute_2_value_2')), 'noupdate': True, - }]"/> + },]"/> </function> + <record id="product_product_4e" model="product.product"> + <field name="default_code">DESK0005</field> + <field name="weight">0.01</field> + </record> + + <record id="product_product_4f" model="product.product"> + <field name="default_code">DESK0006</field> + <field name="weight">0.01</field> + </record> + <record id="product_template_attribute_exclusion_1" model="product.template.attribute.exclusion"> <field name="product_tmpl_id" ref="product.product_product_4_product_template" /> - <field name="value_ids" eval="[(6,0,[ref('sale.product_template_attribute_value_5')])]"/> + <field name="value_ids" eval="[(6,0,[ref('product.product_4_attribute_2_value_2')])]"/> </record> <record id="product_template_attribute_exclusion_2" model="product.template.attribute.exclusion"> <field name="product_tmpl_id" ref="product.product_product_11_product_template" /> - <field name="value_ids" eval="[(6,0,[ref('sale.product_template_attribute_value_4')])]"/> + <field name="value_ids" eval="[(6,0,[ref('product.product_11_attribute_1_value_1')])]"/> </record> <!-- @@ -849,20 +849,20 @@ Thanks!</field> - The "Customizable Desk's Black" attribute - The "Office Chair's Steel" attribute --> - <record id="product.product_template_attribute_value_1" model="product.template.attribute.value"> + <record id="product.product_4_attribute_1_value_2" model="product.template.attribute.value"> <field name="exclude_for" eval="[(6,0,[ref('sale.product_template_attribute_exclusion_1') ,ref('sale.product_template_attribute_exclusion_2')])]" /> </record> <record id="product_template_attribute_exclusion_3" model="product.template.attribute.exclusion"> <field name="product_tmpl_id" ref="product.product_product_11_product_template" /> - <field name="value_ids" eval="[(6,0,[ref('product.product_template_attribute_value_2')])]"/> + <field name="value_ids" eval="[(6,0,[ref('product.product_11_attribute_1_value_2')])]"/> </record> <!-- The "Customizable Desk's Steel" attribute value will excude: - The "Office Chair's Aluminium" attribute --> - <record id="product_template_attribute_value_3" model="product.template.attribute.value"> + <record id="product.product_4_attribute_1_value_1" model="product.template.attribute.value"> <field name="exclude_for" eval="[(6,0,[ref('sale.product_template_attribute_exclusion_3')])]" /> </record> </data> diff --git a/addons/sale/models/product_product.py b/addons/sale/models/product_product.py index c7a402db9a8..973b8375e97 100644 --- a/addons/sale/models/product_product.py +++ b/addons/sale/models/product_product.py @@ -86,10 +86,22 @@ class ProductTemplateAttributeValue(models.Model): class ProductAttributeCustomValue(models.Model): _name = "product.attribute.custom.value" - _rec_name = 'custom_value' _description = 'Product Attribute Custom Value' - _order = 'attribute_value_id, id' - - attribute_value_id = fields.Many2one('product.attribute.value', string='Attribute Value') - sale_order_line_id = fields.Many2one('sale.order.line', string='Sale order line') - custom_value = fields.Char('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') + 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/models/product_template.py b/addons/sale/models/product_template.py index 08f8ec59652..b32a8a5442c 100644 --- a/addons/sale/models/product_template.py +++ b/addons/sale/models/product_template.py @@ -169,7 +169,7 @@ class ProductTemplate(models.Model): """ self.ensure_one() # get the name before the change of context to benefit from prefetch - display_name = self.name + display_name = self.display_name display_image = True quantity = self.env.context.get('quantity', add_qty) @@ -207,15 +207,16 @@ class ProductTemplate(models.Model): list_price = product.price_compute('list_price')[product.id] price = product.price if pricelist else list_price display_image = bool(product.image_1920) + display_name = product.display_name else: product_template = product_template.with_context(current_attributes_price_extra=[v.price_extra or 0.0 for v in combination]) list_price = product_template.price_compute('list_price')[product_template.id] price = product_template.price if pricelist else list_price display_image = bool(product_template.image_1920) - filtered_combination = combination._without_no_variant_attributes() - if filtered_combination: - display_name = '%s (%s)' % (display_name, ', '.join(filtered_combination.mapped('name'))) + combination_name = combination._get_combination_name() + if combination_name: + display_name = "%s (%s)" % (display_name, combination_name) if pricelist and pricelist.currency_id != product_template.currency_id: list_price = product_template.currency_id._convert( diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index 428be0c3363..8ad8f897d45 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -1125,16 +1125,11 @@ class SaleOrderLine(models.Model): product_uom_qty = fields.Float(string='Quantity', digits='Product Unit of Measure', required=True, default=1.0) product_uom = fields.Many2one('uom.uom', string='Unit of Measure', domain="[('category_id', '=', product_uom_category_id)]") product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id', readonly=True) - product_custom_attribute_value_ids = fields.One2many('product.attribute.custom.value', 'sale_order_line_id', string='User entered custom product attribute values') + product_custom_attribute_value_ids = fields.One2many('product.attribute.custom.value', 'sale_order_line_id', string="Custom Values") # M2M holding the values of product.attribute with create_variant field set to 'no_variant' # It allows keeping track of the extra_price associated to those attribute values and add them to the SO line description - # Note: If the attributes are changed on the template, some or all records - # in `product_no_variant_attribute_value_ids` will be removed here, even - # from existing or locked sales order lines. Thus this field can only be - # relied on initially, but it cannot be used to recompute anything later - # because the result might be different then. - product_no_variant_attribute_value_ids = fields.Many2many('product.template.attribute.value', string='Product attribute values that do not create variants') + product_no_variant_attribute_value_ids = fields.Many2many('product.template.attribute.value', string="Extra Values", ondelete='restrict') qty_delivered_method = fields.Selection([ ('manual', 'Manual'), @@ -1380,15 +1375,15 @@ class SaleOrderLine(models.Model): def product_id_change(self): if not self.product_id: return - + valid_values = self.product_id.product_tmpl_id.valid_product_template_attribute_line_ids.product_template_value_ids # remove the is_custom values that don't belong to this template for pacv in self.product_custom_attribute_value_ids: - if pacv.attribute_value_id not in self.product_id.product_tmpl_id.valid_product_attribute_value_ids: + if pacv.custom_product_template_attribute_value_id not in valid_values: self.product_custom_attribute_value_ids -= pacv # remove the no_variant attributes that don't belong to this template for ptav in self.product_no_variant_attribute_value_ids: - if ptav.product_attribute_value_id not in self.product_id.product_tmpl_id.valid_product_attribute_value_ids: + if ptav._origin not in valid_values: self.product_no_variant_attribute_value_ids -= ptav vals = {} @@ -1585,10 +1580,6 @@ class SaleOrderLine(models.Model): itself is not sufficient to create the description: we need to add information about those special attributes and values. - See note about `product_no_variant_attribute_value_ids` above the field - definition: this method is not reliable to recompute the description at - a later time, it should only be used initially. - :return: the description related to special variant attributes/values :rtype: string """ @@ -1597,19 +1588,15 @@ class SaleOrderLine(models.Model): name = "\n" - product_attribute_with_is_custom = self.product_custom_attribute_value_ids.mapped('attribute_value_id.attribute_id') + custom_values = self.product_custom_attribute_value_ids.custom_product_template_attribute_value_id # display the no_variant attributes, except those that are also # displayed by a custom (avoid duplicate) - for no_variant_attribute_value in self.product_no_variant_attribute_value_ids.filtered( - lambda ptav: ptav.attribute_id not in product_attribute_with_is_custom - ): - name += "\n" + no_variant_attribute_value.attribute_id.name + ': ' + no_variant_attribute_value.name + for ptav in (self.product_no_variant_attribute_value_ids - custom_values): + name += "\n" + ptav.display_name # display the is_custom values for pacv in self.product_custom_attribute_value_ids: - name += "\n" + pacv.attribute_value_id.attribute_id.name + \ - ': ' + pacv.attribute_value_id.name + \ - ': ' + (pacv.custom_value or '').strip() + name += "\n" + pacv.display_name return name diff --git a/addons/sale/static/src/js/product_configurator_widget.js b/addons/sale/static/src/js/product_configurator_widget.js index 78e79cf74f0..f60564b6b5b 100644 --- a/addons/sale/static/src/js/product_configurator_widget.js +++ b/addons/sale/static/src/js/product_configurator_widget.js @@ -251,7 +251,7 @@ var ProductConfiguratorWidget = relationalFields.FieldMany2One.extend({ _.each(recordData.data, function (record) { if (record.ref === resId) { convertedValues.push([0, 0, { - attribute_value_id: record.data.attribute_value_id.data.id, + custom_product_template_attribute_value_id: record.data.custom_product_template_attribute_value_id.data.id, custom_value: record.data.custom_value }]); } diff --git a/addons/sale/static/src/js/variant_mixin.js b/addons/sale/static/src/js/variant_mixin.js index 36577e8dedd..2fc61923fb8 100644 --- a/addons/sale/static/src/js/variant_mixin.js +++ b/addons/sale/static/src/js/variant_mixin.js @@ -99,13 +99,13 @@ var VariantMixin = { * @private * @param {MouseEvent} ev */ - handleCustomValues: function ($target){ + handleCustomValues: function ($target) { var $variantContainer; var $customInput = false; if ($target.is('input[type=radio]') && $target.is(':checked')) { $variantContainer = $target.closest('ul').closest('li'); $customInput = $target; - } else if ($target.is('select')){ + } else if ($target.is('select')) { $variantContainer = $target.closest('li'); $customInput = $target .find('option[value="' + $target.val() + '"]'); @@ -119,12 +119,12 @@ var VariantMixin = { if ($variantContainer.find('.variant_custom_value').length === 0 || $variantContainer .find('.variant_custom_value') - .data('attribute_value_id') !== parseInt(attributeValueId)){ + .data('custom_product_template_attribute_value_id') !== parseInt(attributeValueId)) { $variantContainer.find('.variant_custom_value').remove(); var $input = $('<input>', { type: 'text', - 'data-attribute_value_id': attributeValueId, + 'data-custom_product_template_attribute_value_id': attributeValueId, 'data-attribute_value_name': attributeValueName, class: 'variant_custom_value form-control' }); @@ -207,7 +207,7 @@ var VariantMixin = { * * @param {$.Element} $container * @returns {Array} array of custom values with the following format - * {integer} attribute_value_id + * {integer} custom_product_template_attribute_value_id * {string} attribute_value_name * {string} custom_value */ @@ -217,7 +217,7 @@ var VariantMixin = { var $variantCustomValueInput = $(this); if ($variantCustomValueInput.length !== 0){ variantCustomValues.push({ - 'attribute_value_id': $variantCustomValueInput.data('attribute_value_id'), + 'custom_product_template_attribute_value_id': $variantCustomValueInput.data('custom_product_template_attribute_value_id'), 'attribute_value_name': $variantCustomValueInput.data('attribute_value_name'), 'custom_value': $variantCustomValueInput.val(), }); @@ -233,7 +233,7 @@ var VariantMixin = { * * @param {$.Element} $container * @returns {Array} array of attribute values with the following format - * {integer} attribute_value_id + * {integer} custom_product_template_attribute_value_id * {string} attribute_value_name * {integer} value * {string} attribute_name @@ -255,7 +255,7 @@ var VariantMixin = { if ($variantValueInput.length !== 0){ noVariantAttributeValues.push({ - 'attribute_value_id': $variantValueInput.data('value_id'), + 'custom_product_template_attribute_value_id': $variantValueInput.data('value_id'), 'attribute_value_name': $variantValueInput.data('value_name'), 'value': $variantValueInput.val(), 'attribute_name': $variantValueInput.data('attribute_name'), diff --git a/addons/sale/tests/test_sale_product_attribute_value_config.py b/addons/sale/tests/test_sale_product_attribute_value_config.py index 105c0ee3bbe..dc5b7c8ad9a 100644 --- a/addons/sale/tests/test_sale_product_attribute_value_config.py +++ b/addons/sale/tests/test_sale_product_attribute_value_config.py @@ -84,7 +84,7 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup): model which had product_id as required and without cascade on delete. Here we have the sales order line in this situation. - This is a necessary condition for `create_variant_ids` to archive + This is a necessary condition for `_create_variant_ids` to archive instead of delete the variants. """ def do_test(self): @@ -96,8 +96,11 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup): variant = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_1) variant2 = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_2) + self.assertTrue(variant) + self.assertTrue(variant2) + # Create a dummy SO to prevent the variant from being deleted by - # create_variant_ids() because the variant is a related field that + # _create_variant_ids() because the variant is a related field that # is required on the SO line so = self.env['sale.order'].create({'partner_id': 1}) self.env['sale.order.line'].create({ @@ -114,7 +117,6 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup): variant2.active = False # CASE: 1 not archived, 2 archived - # Test invalidate_cache on product.product write self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1)) self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_2)) # CASE: both archived combination (without no_variant) @@ -122,13 +124,13 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup): self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_2)) self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1)) - # CASE: not archived (with no_variant) + # CASE: OK after attribute line removed self.computer_hdd_attribute_lines.unlink() + self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8)) + + # CASE: not archived (with no_variant) self.hdd_attribute.create_variant = 'no_variant' self._add_hdd_attribute_line() - self.computer.create_variant_ids() - computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) - computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2) @@ -141,7 +143,6 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup): # CASE: archived combination has different attributes (including no_variant) self.computer_ssd_attribute_lines.unlink() - self.computer.create_variant_ids() variant4 = self.computer._get_variant_for_combination(computer_ram_8 + computer_hdd_1) self.env['sale.order.line'].create({ @@ -155,7 +156,6 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup): self.computer_hdd_attribute_lines.unlink() self.hdd_attribute.create_variant = 'always' self._add_hdd_attribute_line() - self.computer.create_variant_ids() computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) @@ -177,16 +177,15 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup): do_test(self) # CASE: add back the removed attribute and try everything again - # It will be the same attribute but the ptal and ptav will be different! self.computer_ssd_attribute_lines = self.env['product.template.attribute.line'].create({ 'product_tmpl_id': self.computer.id, 'attribute_id': self.ssd_attribute.id, 'value_ids': [(6, 0, [self.ssd_256.id, self.ssd_512.id])], }) - self.computer.create_variant_ids() computer_ssd_256_after = self._get_product_template_attribute_value(self.ssd_256) - self.assertTrue(computer_ssd_256_after != computer_ssd_256_before) + self.assertEqual(computer_ssd_256_after, computer_ssd_256_before) + self.assertEqual(computer_ssd_256_after.attribute_line_id, computer_ssd_256_before.attribute_line_id) do_test(self) def test_02_get_combination_info(self): @@ -214,8 +213,7 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup): res = self.computer._get_combination_info(self.env['product.template.attribute.value'], computer_variant.id) self.assertEqual(res['product_template_id'], self.computer.id) self.assertEqual(res['product_id'], computer_variant.id) - # the variant has the same name as the template - self.assertEqual(res['display_name'], "Super Computer") + self.assertEqual(res['display_name'], "Super Computer (256 GB, 8 GB, 1 To)") self.assertEqual(res['price'], 2222) self.assertEqual(res['list_price'], 2222) @@ -234,13 +232,14 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup): self.computer_ssd_attribute_lines.unlink() self.ssd_attribute.create_variant = 'no_variant' self._add_ssd_attribute_line() - self.computer.create_variant_ids() computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1 computer_variant_new = self.computer._get_variant_for_combination(combination) + self.assertTrue(computer_variant_new) + res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist) self.assertEqual(res['product_template_id'], self.computer.id) self.assertEqual(res['product_id'], computer_variant_new.id) @@ -252,12 +251,14 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup): self.computer_hdd_attribute_lines.unlink() self.hdd_attribute.create_variant = 'dynamic' self._add_hdd_attribute_line() - self.computer.create_variant_ids() computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1) combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1 + computer_variant_new = self.computer._create_product_variant(combination) + self.assertTrue(computer_variant_new) + res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist) self.assertEqual(res['product_template_id'], self.computer.id) self.assertEqual(res['product_id'], computer_variant_new.id) @@ -266,9 +267,8 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup): self.assertEqual(res['list_price'], 2222 * currency_ratio) # CASE: dynamic combination, no variant existing - # Test invalidate_cache on product.template create_variant_ids + # Test invalidate_cache on product.template _create_variant_ids self._add_keyboard_attribute() - self.computer.create_variant_ids() combination += self._get_product_template_attribute_value(self.keyboard_excluded) res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist) self.assertEqual(res['product_template_id'], self.computer.id) @@ -348,10 +348,8 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup): work with dynamic. If the combination is possible, it should create it. If it's not possible, it should not create it.""" self.computer_hdd_attribute_lines.unlink() - self.computer.create_variant_ids() self.hdd_attribute.create_variant = 'dynamic' self._add_hdd_attribute_line() - self.computer.create_variant_ids() computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256) computer_ram_8 = self._get_product_template_attribute_value(self.ram_8) diff --git a/addons/sale/views/variant_templates.xml b/addons/sale/views/variant_templates.xml index b58f282249a..8e712f4e1d5 100644 --- a/addons/sale/views/variant_templates.xml +++ b/addons/sale/views/variant_templates.xml @@ -7,20 +7,20 @@ <!-- Attributes selection is hidden if there is only one value available and it's not a custom value --> <li t-att-data-attribute_id="ptal.attribute_id.id" t-att-data-attribute_name="ptal.attribute_id.name" - t-attf-class="variant_attribute #{'d-none' if len(ptal.product_template_value_ids) == 1 and not ptal.product_template_value_ids[0].is_custom else ''}"> + t-attf-class="variant_attribute #{'d-none' if len(ptal.product_template_value_ids._only_active()) == 1 and not ptal.product_template_value_ids._only_active()[0].is_custom else ''}"> <!-- Used to customize layout if the only available attribute value is custom --> - <t t-set="single_and_custom" t-value="len(ptal.product_template_value_ids) == 1 and ptal.product_template_value_ids[0].is_custom" /> + <t t-set="single_and_custom" t-value="len(ptal.product_template_value_ids._only_active()) == 1 and ptal.product_template_value_ids._only_active()[0].is_custom" /> <strong t-field="ptal.attribute_id.name" class="attribute_name"/> <t t-if="ptal.attribute_id.type == 'select'"> <select t-att-data-attribute_id="ptal.attribute_id.id" t-attf-class="form-control js_variant_change #{ptal.attribute_id.create_variant} #{'d-none' if single_and_custom else ''}" - t-att-name="'attribute-%s-%s' % (product.id, ptal.attribute_id.id)"> - <t t-foreach="ptal.product_template_value_ids" t-as="ptav"> + t-att-name="'ptal-%s' % ptal.id"> + <t t-foreach="ptal.product_template_value_ids._only_active()" t-as="ptav"> <option t-att-value="ptav.id" - t-att-data-value_id="ptav.product_attribute_value_id.id" + t-att-data-value_id="ptav.id" t-att-data-value_name="ptav.name" t-att-data-attribute_name="ptav.attribute_id.name" t-att-data-is_custom="ptav.is_custom" @@ -52,7 +52,7 @@ <t t-if="ptal.attribute_id.type == 'radio'"> <ul t-att-data-attribute_id="ptal.attribute_id.id" t-attf-class="list-unstyled #{'d-none' if single_and_custom else ''}"> - <t t-foreach="ptal.product_template_value_ids" t-as="ptav"> + <t t-foreach="ptal.product_template_value_ids._only_active()" t-as="ptav"> <li class="form-group js_attribute_value" style="margin: 0;"> <label class="col-form-label"> <div> @@ -61,7 +61,7 @@ t-att-checked="ptav in combination" t-att-name="'attribute-%s-%s' % (product.id, ptal.attribute_id.id)" t-att-value="ptav.id" - t-att-data-value_id="ptav.product_attribute_value_id.id" + t-att-data-value_id="ptav.id" t-att-data-value_name="ptav.name" t-att-data-attribute_name="ptav.attribute_id.name" t-att-data-is_custom="ptav.is_custom" @@ -88,8 +88,8 @@ <t t-if="ptal.attribute_id.type == 'color'"> <ul t-att-data-attribute_id="ptal.attribute_id.id" t-attf-class="list-inline #{'d-none' if single_and_custom else ''}"> - <li t-foreach="ptal.product_template_value_ids" t-as="ptav" class="list-inline-item"> - <label t-attf-style="background-color:#{ptav.html_color or ptav.name if not ptav.is_custom else ''}" + <li t-foreach="ptal.product_template_value_ids._only_active()" t-as="ptav" class="list-inline-item"> + <label t-attf-style="background-color:#{ptav.html_color or ptav.product_attribute_value_id.name if not ptav.is_custom else ''}" t-attf-class="css_attribute_color #{'active' if ptav in combination else ''} #{'custom_value' if ptav.is_custom else ''}"> <input type="radio" t-attf-class="js_variant_change #{ptal.attribute_id.create_variant}" @@ -97,7 +97,7 @@ t-att-name="'attribute-%s-%s' % (product.id, ptal.attribute_id.id)" t-att-value="ptav.id" t-att-title="ptav.name" - t-att-data-value_id="ptav.product_attribute_value_id.id" + t-att-data-value_id="ptav.id" t-att-data-value_name="ptav.name" t-att-data-attribute_name="ptav.attribute_id.name" t-att-data-is_custom="ptav.is_custom" diff --git a/addons/sale_product_configurator/static/src/js/product_configurator_renderer.js b/addons/sale_product_configurator/static/src/js/product_configurator_renderer.js index 9fe6372d3ed..f0009e42c38 100644 --- a/addons/sale_product_configurator/static/src/js/product_configurator_renderer.js +++ b/addons/sale_product_configurator/static/src/js/product_configurator_renderer.js @@ -100,7 +100,7 @@ var ProductConfiguratorFormRenderer = FormRenderer.extend(VariantMixin, { if (customValueIds) { _.each(customValueIds.data, function (customValue) { if (customValue.data.custom_value) { - var attributeValueId = customValue.data.attribute_value_id.data.id; + var attributeValueId = customValue.data.custom_product_template_attribute_value_id.data.id; var $input = self._findRelatedAttributeValueInput(attributeValueId); $input .closest('li[data-attribute_id]') diff --git a/addons/sale_product_configurator/static/src/js/product_configurator_widget.js b/addons/sale_product_configurator/static/src/js/product_configurator_widget.js index 8ed2fd43f0e..e10cc7c8898 100644 --- a/addons/sale_product_configurator/static/src/js/product_configurator_widget.js +++ b/addons/sale_product_configurator/static/src/js/product_configurator_widget.js @@ -265,7 +265,7 @@ ProductConfiguratorWidget.include({ customValuesCommands.push({ operation: 'CREATE', context: [{ - default_attribute_value_id: customValue.attribute_value_id, + default_custom_product_template_attribute_value_id: customValue.custom_product_template_attribute_value_id, default_custom_value: customValue.custom_value }] }); @@ -341,7 +341,7 @@ ProductConfiguratorWidget.include({ _.each(product.product_custom_attribute_values, function (attributeValue) { defaultCustomAttributeValues.push( [0, 0, { - attribute_value_id: attributeValue.attribute_value_id, + custom_product_template_attribute_value_id: attributeValue.custom_product_template_attribute_value_id, custom_value: attributeValue.custom_value }] ); diff --git a/addons/sale_product_configurator/static/tests/tours/product_configurator_advanced_ui.js b/addons/sale_product_configurator/static/tests/tours/product_configurator_advanced_ui.js index 514dc5b8d8d..f2499dfad96 100644 --- a/addons/sale_product_configurator/static/tests/tours/product_configurator_advanced_ui.js +++ b/addons/sale_product_configurator/static/tests/tours/product_configurator_advanced_ui.js @@ -95,7 +95,7 @@ tour.register('sale_product_configurator_advanced_tour', { extra_trigger: '.oe_optional_products_modal', run: function () {} //check }, { - trigger: '.main_product div:contains("PA5: PAV1")', + trigger: '.main_product div:contains("PA7: PAV1")', extra_trigger: '.oe_optional_products_modal', run: function () {} //check }, { @@ -131,17 +131,17 @@ tour.register('sale_product_configurator_advanced_tour', { in_modal: false, run: function (){} //check }, { - trigger: 'td.o_data_cell:contains("Custom: Custom 1")', + trigger: 'td.o_data_cell:contains("Legs: Custom: Custom 1")', extra_trigger: 'div[name="order_line"]', in_modal: false, run: function (){} //check }, { - trigger: 'td.o_data_cell:contains("PAV9: Custom 2")', + trigger: 'td.o_data_cell:contains("PA1: PAV9: Custom 2")', extra_trigger: 'div[name="order_line"]', in_modal: false, run: function (){} //check }, { - trigger: 'td.o_data_cell:contains("PAV9: Custom 3")', + trigger: 'td.o_data_cell:contains("PA4: PAV9: Custom 3")', extra_trigger: 'div[name="order_line"]', in_modal: false, run: function (){} //check @@ -151,7 +151,7 @@ tour.register('sale_product_configurator_advanced_tour', { in_modal: false, run: function (){} //check }, { - trigger: 'td.o_data_cell:contains("PA5: PAV1")', + trigger: 'td.o_data_cell:contains("PA7: PAV1")', extra_trigger: 'div[name="order_line"]', in_modal: false, run: function (){} //check diff --git a/addons/sale_product_configurator/tests/test_sale_product_configurator_ui.py b/addons/sale_product_configurator/tests/test_sale_product_configurator_ui.py index e2d155c1b23..d4940006d6d 100644 --- a/addons/sale_product_configurator/tests/test_sale_product_configurator_ui.py +++ b/addons/sale_product_configurator/tests/test_sale_product_configurator_ui.py @@ -57,7 +57,7 @@ class TestUi(odoo.tests.HttpCase): 'create_variant': 'no_variant' }]) - product_attribute_values = self.env['product.attribute.value'].create([{ + self.env['product.attribute.value'].create([{ 'name': 'PAV' + str(i), 'is_custom': i == 9, 'attribute_id': product_attribute.id @@ -68,13 +68,9 @@ class TestUi(odoo.tests.HttpCase): self.env['product.template.attribute.line'].create([{ 'attribute_id': product_attribute.id, 'product_tmpl_id': product_template.id, - 'value_ids': [(6, 0, product_attribute_values.filtered( - lambda product_attribute_value: product_attribute_value.attribute_id == product_attribute - ).ids)] + 'value_ids': [(6, 0, product_attribute.value_ids.ids)], } for product_attribute in product_attributes]) - product_template.create_variant_ids() - self.start_tour("/web", 'sale_product_configurator_advanced_tour', login="admin") def test_03_product_configurator_edition(self): @@ -116,8 +112,6 @@ class TestUi(odoo.tests.HttpCase): 'value_ids': [(6, 0, [product_attribute_values[0].id])] }]) - product_template.create_variant_ids() - self.start_tour("/web", 'sale_product_configurator_single_custom_attribute_tour', login="admin") def test_05_product_configurator_pricelist(self): diff --git a/addons/sale_product_configurator/views/sale_views.xml b/addons/sale_product_configurator/views/sale_views.xml index 759469b1fc6..a3ee67a7bfa 100644 --- a/addons/sale_product_configurator/views/sale_views.xml +++ b/addons/sale_product_configurator/views/sale_views.xml @@ -27,7 +27,7 @@ <field name="product_template_attribute_value_ids" invisible="1" /> <field name="product_custom_attribute_value_ids" invisible="1" > <tree> - <field name="attribute_value_id" /> + <field name="custom_product_template_attribute_value_id" /> <field name="custom_value" /> </tree> </field> diff --git a/addons/sale_product_configurator/wizard/sale_product_configurator.py b/addons/sale_product_configurator/wizard/sale_product_configurator.py index 2d76f6267e7..4d636383471 100644 --- a/addons/sale_product_configurator/wizard/sale_product_configurator.py +++ b/addons/sale_product_configurator/wizard/sale_product_configurator.py @@ -14,8 +14,8 @@ class SaleProductConfigurator(models.TransientModel): quantity = fields.Integer('Quantity') pricelist_id = fields.Many2one('product.pricelist', 'Pricelist', readonly=True) product_template_attribute_value_ids = fields.Many2many( - 'product.template.attribute.value', 'product_configurator_template_attribute_value_rel', string='Template Attribute Values', readonly=True) + 'product.template.attribute.value', 'product_configurator_template_attribute_value_rel', string='Attribute Values', readonly=True) product_custom_attribute_value_ids = fields.Many2many( - 'product.attribute.custom.value', 'product_configurator_custom_attribute_value_rel', string='User entered custom product attribute values') + 'product.attribute.custom.value', 'product_configurator_custom_attribute_value_rel', string="Custom Values") product_no_variant_attribute_value_ids = fields.Many2many( - 'product.template.attribute.value', 'product_configurator_no_variant_attribute_value_rel', string='Product attribute values that do not create variants') + 'product.template.attribute.value', 'product_configurator_no_variant_attribute_value_rel', string="Extra Values") diff --git a/addons/sale_product_configurator/wizard/sale_product_configurator_views.xml b/addons/sale_product_configurator/wizard/sale_product_configurator_views.xml index fcf029a59a8..8286db9c0bb 100644 --- a/addons/sale_product_configurator/wizard/sale_product_configurator_views.xml +++ b/addons/sale_product_configurator/wizard/sale_product_configurator_views.xml @@ -10,7 +10,7 @@ <field name="product_template_attribute_value_ids" invisible="1" /> <field name="product_custom_attribute_value_ids" invisible="1" widget="one2many" > <tree> - <field name="attribute_value_id"/> + <field name="custom_product_template_attribute_value_id"/> <field name="custom_value"/> </tree> </field> diff --git a/addons/stock_account/views/stock_account_views.xml b/addons/stock_account/views/stock_account_views.xml index 549140290b7..bf6cab4cbe4 100644 --- a/addons/stock_account/views/stock_account_views.xml +++ b/addons/stock_account/views/stock_account_views.xml @@ -75,7 +75,7 @@ <field name="name" string="Product"/> <field name="categ_id" /> <field name="product_tmpl_id" /> - <field name="attribute_value_ids" /> + <field name="product_template_attribute_value_ids" /> <filter string="Category" name="categ_id" context="{'group_by':'categ_id'}"/> <separator/> <filter string="Archived" name="inactive" domain="[('active','=',False)]"/> diff --git a/addons/website_sale/data/demo.xml b/addons/website_sale/data/demo.xml index dd27704fa7a..3e38aadb212 100644 --- a/addons/website_sale/data/demo.xml +++ b/addons/website_sale/data/demo.xml @@ -635,33 +635,45 @@ <field name="public_categ_ids" eval="[(6, 0, [ref('website_sale.services')])]"/> </record> - <record id="product_template_attribute_line_1" model="product.template.attribute.line"> + <record id="product_1_attribute_3_product_template_attribute_line" model="product.template.attribute.line"> <field name="product_tmpl_id" ref="website_sale.product_product_1_product_template"/> <field name="attribute_id" ref="product.product_attribute_3"/> <field name="value_ids" eval="[(6,0,[ref('product.product_attribute_value_5'), ref('product.product_attribute_value_6')])]"/> </record> - <record id="product_product_1" model="product.product"> - <field name="default_code">SERV_125889</field> - <field name="product_tmpl_id" ref="website_sale.product_product_1_product_template"/> - <field name="attribute_value_ids" eval="[(6, 0, [ref('product.product_attribute_value_5')])]"/> - </record> - <record id="product_product_1b" model="product.product"> - <field name="default_code">SERV_125890</field> - <field name="product_tmpl_id" ref="website_sale.product_product_1_product_template"/> - <field name="attribute_value_ids" eval="[(6, 0, [ref('product.product_attribute_value_6')])]"/> - </record> - <!-- Handle automatically created product.template.attribute.value --> <function model="ir.model.data" name="_update_xmlids"> <value model="base" eval="[{ - 'xml_id': 'website_sale.product_template_attribute_value_1', - 'record': obj().env.ref('website_sale.product_template_attribute_line_1').product_template_value_ids[1], + 'xml_id': 'website_sale.product_1_attribute_3_value_1', + 'record': obj().env.ref('website_sale.product_1_attribute_3_product_template_attribute_line').product_template_value_ids[0], + 'noupdate': True, + }, { + 'xml_id': 'website_sale.product_1_attribute_3_value_2', + 'record': obj().env.ref('website_sale.product_1_attribute_3_product_template_attribute_line').product_template_value_ids[1], 'noupdate': True, }]"/> </function> - <record id="product_template_attribute_value_1" model="product.template.attribute.value"> + <function model="ir.model.data" name="_update_xmlids"> + <value model="base" eval="[{ + 'xml_id': 'website_sale.product_product_1', + 'record': obj().env.ref('website_sale.product_product_1_product_template')._get_variant_for_combination(obj().env.ref('website_sale.product_1_attribute_3_value_1')), + 'noupdate': True, + }, { + 'xml_id': 'website_sale.product_product_1b', + 'record': obj().env.ref('website_sale.product_product_1_product_template')._get_variant_for_combination(obj().env.ref('website_sale.product_1_attribute_3_value_2')), + 'noupdate': True, + },]"/> + </function> + + <record id="product_product_1" model="product.product"> + <field name="default_code">SERV_125889</field> + </record> + <record id="product_product_1b" model="product.product"> + <field name="default_code">SERV_125890</field> + </record> + + <record id="website_sale.product_1_attribute_3_value_2" model="product.template.attribute.value"> <field name="price_extra">18.00</field> </record> </odoo> diff --git a/addons/website_sale/models/product.py b/addons/website_sale/models/product.py index d3f80b639d4..1426237f595 100644 --- a/addons/website_sale/models/product.py +++ b/addons/website_sale/models/product.py @@ -202,7 +202,7 @@ class ProductTemplate(models.Model): :rtype: bool """ self.ensure_one() - return any(a.create_variant == 'no_variant' for a in self.valid_product_attribute_ids) + return any(a.create_variant == 'no_variant' for a in self.valid_product_template_attribute_line_ids.attribute_id) def _has_is_custom_values(self): self.ensure_one() @@ -212,7 +212,7 @@ class ProductTemplate(models.Model): :return: True if at least one is_custom attribute value, False otherwise :rtype: bool """ - return any(v.is_custom for v in self.valid_product_attribute_value_ids) + return any(v.is_custom for v in self.valid_product_template_attribute_line_ids.product_template_value_ids._only_active()) def _get_possible_variants_sorted(self, parent_combination=None): """Return the sorted recordset of variants that are possible. @@ -243,9 +243,9 @@ class ProductTemplate(models.Model): - second level sort: same as "product.attribute.value"._order """ keys = [] - for attribute in variant.attribute_value_ids.sorted(_sort_key_attribute_value): + for attribute in variant.product_template_attribute_value_ids.sorted(_sort_key_attribute_value): # if you change this order, keep it in sync with _order from `product.attribute.value` - keys.append(attribute.sequence) + keys.append(attribute.product_attribute_value_id.sequence) keys.append(attribute.id) return keys @@ -402,10 +402,10 @@ class Product(models.Model): website_url = fields.Char('Website URL', compute='_compute_product_website_url', help='The full URL to access the document through the website.') - @api.depends('product_tmpl_id.website_url', 'attribute_value_ids') + @api.depends('product_tmpl_id.website_url', 'product_template_attribute_value_ids') def _compute_product_website_url(self): for product in self: - attributes = ','.join(str(x) for x in product.attribute_value_ids.ids) + attributes = ','.join(str(x) for x in product.product_template_attribute_value_ids.ids) product.website_url = "%s#attr=%s" % (product.product_tmpl_id.website_url, attributes) def website_publish_button(self): diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py index fac0ae04b6e..49accd853c1 100644 --- a/addons/website_sale/models/sale_order.py +++ b/addons/website_sale/models/sale_order.py @@ -196,8 +196,6 @@ class SaleOrder(models.Model): for ptav in combination.filtered(lambda ptav: ptav.attribute_id.create_variant == 'no_variant' and ptav not in received_no_variant_values): no_variant_attribute_values.append({ 'value': ptav.id, - 'attribute_name': ptav.attribute_id.name, - 'attribute_value_name': ptav.name, }) # save no_variant attributes values @@ -208,19 +206,18 @@ class SaleOrder(models.Model): # add is_custom attribute values that were not received custom_values = kwargs.get('product_custom_attribute_values') or [] - received_custom_values = product.env['product.attribute.value'].browse([int(ptav['attribute_value_id']) for ptav in custom_values]) + received_custom_values = product.env['product.template.attribute.value'].browse([int(ptav['custom_product_template_attribute_value_id']) for ptav in custom_values]) - for ptav in combination.filtered(lambda ptav: ptav.is_custom and ptav.product_attribute_value_id not in received_custom_values): + for ptav in combination.filtered(lambda ptav: ptav.is_custom and ptav not in received_custom_values): custom_values.append({ - 'attribute_value_id': ptav.product_attribute_value_id.id, - 'attribute_value_name': ptav.name, + 'custom_product_template_attribute_value_id': ptav.id, 'custom_value': '', }) # save is_custom attributes values if custom_values: values['product_custom_attribute_value_ids'] = [(0, 0, { - 'attribute_value_id': custom_value['attribute_value_id'], + 'custom_product_template_attribute_value_id': custom_value['custom_product_template_attribute_value_id'], 'custom_value': custom_value['custom_value'] }) for custom_value in custom_values] diff --git a/addons/website_sale/tests/test_customize.py b/addons/website_sale/tests/test_customize.py index d120d7f6651..c306639eec4 100644 --- a/addons/website_sale/tests/test_customize.py +++ b/addons/website_sale/tests/test_customize.py @@ -39,8 +39,6 @@ class TestUi(odoo.tests.HttpCase): else: ptav.price_extra = 50.4 - product_template.create_variant_ids() - def test_01_admin_shop_customize_tour(self): self.start_tour("/", 'shop_customize', login="admin") @@ -165,8 +163,6 @@ class TestUi(odoo.tests.HttpCase): product_template_attribute_values[1].price_extra = 20 product_template_attribute_values[2].price_extra = 30 - product_template.create_variant_ids() - # archive first combination (first variant) product_template.product_variant_ids[0].active = False # delete second combination (which is now first variant since cache has been cleared) @@ -209,8 +205,6 @@ class TestUi(odoo.tests.HttpCase): # set a price on the value ptal.product_template_value_ids.price_extra = 10 - product_template.create_variant_ids() - self.start_tour("/", 'tour_shop_no_variant_attribute', login="demo") def test_06_admin_list_view_b2c(self): diff --git a/addons/website_sale/tests/test_website_sale_image.py b/addons/website_sale/tests/test_website_sale_image.py index ad6fcb15300..bda48819247 100644 --- a/addons/website_sale/tests/test_website_sale_image.py +++ b/addons/website_sale/tests/test_website_sale_image.py @@ -93,11 +93,13 @@ class TestWebsiteSaleImage(odoo.tests.HttpCase): }) # set the color attribute and values on the template - self.env['product.template.attribute.line'].create([{ + line = self.env['product.template.attribute.line'].create([{ 'attribute_id': product_attribute.id, 'product_tmpl_id': template.id, 'value_ids': [(6, 0, attr_values.ids)] }]) + value_red = line.product_template_value_ids[0] + value_green = line.product_template_value_ids[1] # set a different price on the variants to differentiate them product_template_attribute_values = self.env['product.template.attribute.value'].search([('product_tmpl_id', '=', template.id)]) @@ -108,22 +110,20 @@ class TestWebsiteSaleImage(odoo.tests.HttpCase): else: val.price_extra = 20 - # Create RED variant, and set image to blue (will be set on the template + # Get RED variant, and set image to blue (will be set on the template # because the template image is empty and there is only one variant) - product_red = self.env['product.product'].create({ - 'product_tmpl_id': template.id, + product_red = template._get_variant_for_combination(value_red) + product_red.write({ 'image_1920': blue_image, - 'attribute_value_ids': [(6, 0, attr_values.filtered(lambda l: l.name == name_red).ids)], 'product_variant_image_ids': [(0, 0, {'name': 'image 2', 'image_1920': image_bmp})], }) self.assertEqual(template.image_1920, blue_image) - # create the green variant - product_green = self.env['product.product'].create({ + # Get the green variant + product_green = template._get_variant_for_combination(value_green) + product_green.write({ 'image_1920': green_image, - 'product_tmpl_id': template.id, - 'attribute_value_ids': [(6, 0, attr_values.filtered(lambda l: l.name == name_green).ids)], 'product_variant_image_ids': [(0, 0, {'name': 'image 3', 'image_1920': image_png})], }) diff --git a/addons/website_sale_comparison/controllers/main.py b/addons/website_sale_comparison/controllers/main.py index a2dac3d4904..f40ea82c865 100644 --- a/addons/website_sale_comparison/controllers/main.py +++ b/addons/website_sale_comparison/controllers/main.py @@ -27,7 +27,7 @@ class WebsiteSaleProductComparison(WebsiteSale): for var in product.product_tmpl_id.valid_product_template_attribute_line_ids.filtered(lambda x: x.attribute_id.create_variant): cat_name = var.attribute_id.category_id.name or _('Uncategorized') att_name = var.attribute_id.name - val = product.attribute_value_ids.filtered(lambda x: x.attribute_id == var.attribute_id) + val = product.product_template_attribute_value_ids.product_attribute_value_id.filtered(lambda x: x.attribute_id == var.attribute_id) if val: res[cat_name][att_name][num] = val[0].name values['specs'] = res diff --git a/addons/website_sale_comparison/data/website_sale_comparison_demo.xml b/addons/website_sale_comparison/data/website_sale_comparison_demo.xml index 3e2b9c7d682..1008bbc1bfc 100644 --- a/addons/website_sale_comparison/data/website_sale_comparison_demo.xml +++ b/addons/website_sale_comparison/data/website_sale_comparison_demo.xml @@ -49,23 +49,20 @@ <field name="attribute_id" ref="product_attribute_8"/> </record> - <record id="product_template_attribute_line_1" model="product.template.attribute.line"> + <record id="product_6_attribute_1_product_template_attribute_line" model="product.template.attribute.line"> <field name="product_tmpl_id" ref="product.product_product_6_product_template"/> <field name="attribute_id" ref="product_attribute_1"/> <field name="value_ids" eval="[(6,0,[ref('product_attribute_value_1')])]"/> </record> - <record id="product_template_attribute_line_7" model="product.template.attribute.line"> + <record id="product_6_attribute_7_product_template_attribute_line" model="product.template.attribute.line"> <field name="product_tmpl_id" ref="product.product_product_6_product_template"/> <field name="attribute_id" ref="product_attribute_7"/> <field name="value_ids" eval="[(6,0,[ref('product_attribute_value_7')])]"/> </record> - <record id="product_template_attribute_line_8" model="product.template.attribute.line"> + <record id="product_6_attribute_8_template_attribute_line" model="product.template.attribute.line"> <field name="product_tmpl_id" ref="product.product_product_6_product_template"/> <field name="attribute_id" ref="product_attribute_8"/> <field name="value_ids" eval="[(6,0,[ref('product_attribute_value_8')])]"/> </record> - <function model="product.template" name="create_variant_ids"> - <value model="product.template" eval="[ref('product.product_product_6_product_template')]" /> - </function> </odoo> |
