From 02b8cf3af0a266e70ca1de16b6c65f06b8804ad2 Mon Sep 17 00:00:00 2001 From: vivah-odoo Date: Wed, 24 Jun 2026 18:42:29 +0530 Subject: [PATCH] [ADD] product_kit: add product kit definition Add 'is_kit' boolean and product.kit.line model to define kit components on product templates. Include form view with is_kit toggle, Kit Components stat button, and notebook page. Add kit_line_ids/parent_kit_line_id on sale.order.line with unlink cascade. Override action_confirm to explode kits into component lines. Add gear icon on order lines for kit products. Add TransientModel wizard to configure kit component quantities and prices on sale order lines. Pre-populates from kit definition and converts parent line to section header on confirm. --- product_kit/__init__.py | 2 + product_kit/__manifest__.py | 16 +++ product_kit/models/__init__.py | 4 + product_kit/models/product_kit_line.py | 47 ++++++++ product_kit/models/product_template.py | 16 +++ product_kit/models/sale_order.py | 76 ++++++++++++ product_kit/models/sale_order_line.py | 84 +++++++++++++ product_kit/security/ir.model.access.csv | 5 + product_kit/views/kit_config_wizard_views.xml | 41 +++++++ product_kit/views/product_template_views.xml | 63 ++++++++++ product_kit/views/sale_order_views.xml | 30 +++++ product_kit/wizard/__init__.py | 2 + product_kit/wizard/kit_config_wizard.py | 111 ++++++++++++++++++ product_kit/wizard/kit_config_wizard_line.py | 37 ++++++ 14 files changed, 534 insertions(+) create mode 100644 product_kit/__init__.py create mode 100644 product_kit/__manifest__.py create mode 100644 product_kit/models/__init__.py create mode 100644 product_kit/models/product_kit_line.py create mode 100644 product_kit/models/product_template.py create mode 100644 product_kit/models/sale_order.py create mode 100644 product_kit/models/sale_order_line.py create mode 100644 product_kit/security/ir.model.access.csv create mode 100644 product_kit/views/kit_config_wizard_views.xml create mode 100644 product_kit/views/product_template_views.xml create mode 100644 product_kit/views/sale_order_views.xml create mode 100644 product_kit/wizard/__init__.py create mode 100644 product_kit/wizard/kit_config_wizard.py create mode 100644 product_kit/wizard/kit_config_wizard_line.py diff --git a/product_kit/__init__.py b/product_kit/__init__.py new file mode 100644 index 00000000000..9b4296142f4 --- /dev/null +++ b/product_kit/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/product_kit/__manifest__.py b/product_kit/__manifest__.py new file mode 100644 index 00000000000..c67196c38ac --- /dev/null +++ b/product_kit/__manifest__.py @@ -0,0 +1,16 @@ +{ + 'name': 'Product Kit', + 'version': '1.0', + 'depends': ['base', 'sale', 'product', 'stock'], + 'application': True, + 'installable': True, + 'author': "odoo s.a", + 'category': 'Tutorials', + 'license': 'AGPL-3', + 'data': [ + 'security/ir.model.access.csv', + 'views/product_template_views.xml', + 'views/sale_order_views.xml', + 'views/kit_config_wizard_views.xml', + ], +} diff --git a/product_kit/models/__init__.py b/product_kit/models/__init__.py new file mode 100644 index 00000000000..dad6638633f --- /dev/null +++ b/product_kit/models/__init__.py @@ -0,0 +1,4 @@ +from . import product_template +from . import product_kit_line +from . import sale_order +from . import sale_order_line diff --git a/product_kit/models/product_kit_line.py b/product_kit/models/product_kit_line.py new file mode 100644 index 00000000000..32b1819fe62 --- /dev/null +++ b/product_kit/models/product_kit_line.py @@ -0,0 +1,47 @@ +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + + +class ProductKitLine(models.Model): + _name = 'product.kit.line' + _description = 'Product Kit Line' + _rec_name = 'product_id' + + product_tmpl_id = fields.Many2one( + 'product.template', + string="Kit Product", + required=True, + ondelete='cascade', + ) + product_id = fields.Many2one( + 'product.product', + string="Component Product", + required=True, + ) + quantity = fields.Float( + string="Quantity", + required=True, + default=1.0, + ) + + @api.constrains('product_id') + def _check_product_id(self): + """Validate that the component product is not the same as the kit product itself.""" + for line in self: + if line.product_id and line.product_id.product_tmpl_id == line.product_tmpl_id: + raise ValidationError( + _("A product cannot be a component of itself.") + ) + + def write(self, vals): + """Prevent modification of restricted fields after creation.""" + restricted_fields = {'product_id', 'quantity', 'product_tmpl_id'} + modified = restricted_fields & set(vals.keys()) + if modified: + field_names = dict(self.fields_get(modified)).keys() + raise UserError( + _("Kit line fields (%s) cannot be modified after creation. " + "Delete and recreate the line instead.") + % ", ".join(field_names) + ) + return super(ProductKitLine, self).write(vals) diff --git a/product_kit/models/product_template.py b/product_kit/models/product_template.py new file mode 100644 index 00000000000..5736e213fe1 --- /dev/null +++ b/product_kit/models/product_template.py @@ -0,0 +1,16 @@ +from odoo import fields, models + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + is_kit = fields.Boolean( + string="Is a Kit", + help="Check if this product is a kit composed of multiple products.", + ) + kit_product_ids = fields.One2many( + 'product.kit.line', + 'product_tmpl_id', + string="Kit Products", + help="Products that make up this kit.", + ) diff --git a/product_kit/models/sale_order.py b/product_kit/models/sale_order.py new file mode 100644 index 00000000000..31b64204401 --- /dev/null +++ b/product_kit/models/sale_order.py @@ -0,0 +1,76 @@ +from odoo import _, api, fields, models + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + @api.depends('order_line.kit_line_ids', 'order_line.parent_kit_line_id') + def _compute_kit_lines_count(self): + for order in self: + order.kit_lines_count = len( + order.order_line.filtered(lambda l: l.kit_line_ids or l.parent_kit_line_id) + ) + + kit_lines_count = fields.Integer( + string="Kit Lines", + compute='_compute_kit_lines_count', + ) + + def action_open_kit_config_wizard(self): + + self.ensure_one() + + context = self.env.context.copy() + active_line_id = context.get('active_id') + return { + 'type': 'ir.actions.act_window', + 'res_model': 'kit.config.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'default_sale_order_id': self.id, + 'default_sale_line_id': active_line_id, + 'active_ids': self.ids, + }, + } + + def action_confirm(self): + + res = super(SaleOrder, self).action_confirm() + + for order in self: + lines_to_process = order.order_line.filtered( + lambda l: l.product_id and not l.kit_line_ids + and l.product_id.product_tmpl_id.is_kit + and l.product_id.product_tmpl_id.kit_product_ids + ) + for line in lines_to_process: + kit_template = line.product_id.product_tmpl_id + + section_seq = line.sequence + 1 + self.env['sale.order.line'].create({ + 'order_id': order.id, + 'display_type': 'line_section', + 'name': _("Sub products of %s", line.name), + 'sequence': section_seq, + }) + + for idx, kit_component in enumerate(kit_template.kit_product_ids): + vals = { + 'order_id': order.id, + 'product_id': kit_component.product_id.id, + 'product_uom_qty': line.product_uom_qty * kit_component.quantity, + 'price_unit': kit_component.product_id.lst_price, + 'name': kit_component.product_id.display_name, + 'parent_kit_line_id': line.id, + 'sequence': section_seq + 1 + idx, + } + child_line = self.env['sale.order.line'].create(vals) + line.write({'kit_line_ids': [(4, child_line.id)]}) + + vals = {'product_uom_qty': 0} + if line.name and not line.name.startswith('[Kit] '): + vals['name'] = f"[Kit] {line.name}" + line.write(vals) + + return res diff --git a/product_kit/models/sale_order_line.py b/product_kit/models/sale_order_line.py new file mode 100644 index 00000000000..10fc732d808 --- /dev/null +++ b/product_kit/models/sale_order_line.py @@ -0,0 +1,84 @@ +from odoo import api, fields, models + + +class SaleOrderLine(models.Model): + _inherit = 'sale.order.line' + + kit_line_ids = fields.One2many( + 'sale.order.line', + 'parent_kit_line_id', + string="Kit Lines", + help="Sale order lines generated from exploding this kit.", + ) + parent_kit_line_id = fields.Many2one( + 'sale.order.line', + string="Parent Kit Line", + help="Parent kit sale order line that generated this line.", + ondelete='cascade', + ) + + is_kit = fields.Boolean( + string="Is Kit", + compute='_compute_is_kit', + help="Whether the product on this line is a kit.", + ) + + @api.depends('product_id') + def _compute_is_kit(self): + for line in self: + line.is_kit = bool( + line.product_id + and line.product_id.product_tmpl_id.is_kit + and line.product_id.product_tmpl_id.kit_product_ids + ) + + def action_open_kit_config_wizard(self): + + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'res_model': 'kit.config.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'default_sale_order_id': self.order_id.id, + 'default_sale_line_id': self.id, + 'active_ids': self.order_id.ids, + }, + } + + def unlink(self): + + kit_child_lines = self.env['sale.order.line'] + kit_section_lines = self.env['sale.order.line'] + for line in self: + if line.kit_line_ids: + kit_child_lines |= line.kit_line_ids + # Find and remove the section header that sits between the kit line and its components + section_line = self.search([ + ('order_id', '=', line.order_id.id), + ('display_type', '=', 'line_section'), + ('sequence', '=', line.sequence + 1), + ], limit=1) + if section_line: + kit_section_lines |= section_line + if kit_child_lines: + kit_child_lines.unlink() + if kit_section_lines: + kit_section_lines.unlink() + return super(SaleOrderLine, self).unlink() + + def _get_kit_component_lines(self): + + self.ensure_one() + return self.kit_line_ids + + def _is_kit_line(self): + + self.ensure_one() + return bool(self.kit_line_ids) + + def _is_kit_component(self): + + self.ensure_one() + return bool(self.parent_kit_line_id) diff --git a/product_kit/security/ir.model.access.csv b/product_kit/security/ir.model.access.csv new file mode 100644 index 00000000000..5a5fd8b7dc2 --- /dev/null +++ b/product_kit/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink + +access_product_kit_line,access.product.kit.line,model_product_kit_line,base.group_user,1,1,1,0 +access_kit_config_wizard,access.kit.config.wizard,model_kit_config_wizard,base.group_user,1,1,1,1 +access_kit_config_wizard_line,access.kit.config.wizard.line,model_kit_config_wizard_line,base.group_user,1,1,1,1 diff --git a/product_kit/views/kit_config_wizard_views.xml b/product_kit/views/kit_config_wizard_views.xml new file mode 100644 index 00000000000..1a2596e259f --- /dev/null +++ b/product_kit/views/kit_config_wizard_views.xml @@ -0,0 +1,41 @@ + + + + kit.config.wizard.form + kit.config.wizard + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
diff --git a/product_kit/views/product_template_views.xml b/product_kit/views/product_template_views.xml new file mode 100644 index 00000000000..57b892972d1 --- /dev/null +++ b/product_kit/views/product_template_views.xml @@ -0,0 +1,63 @@ + + + + Sub Products + product.kit.line + list,form + [('product_tmpl_id', '=', active_id)] + {'default_product_tmpl_id': active_id} + + + + product.kit.line.list + product.kit.line + + + + + + + + + + + product.template.kit.form.inherit + product.template + + +
+ + + +
+ +
+ +
+ + + + + + +
+
+ + + product.template.kit.search.inherit + product.template + + + + + + + +
diff --git a/product_kit/views/sale_order_views.xml b/product_kit/views/sale_order_views.xml new file mode 100644 index 00000000000..2a0e1087dcf --- /dev/null +++ b/product_kit/views/sale_order_views.xml @@ -0,0 +1,30 @@ + + + + + sale.order.kit.form.inherit + sale.order + + + + +