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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/product_kit/wizard/__init__.py b/product_kit/wizard/__init__.py
new file mode 100644
index 00000000000..fb7c5009cf9
--- /dev/null
+++ b/product_kit/wizard/__init__.py
@@ -0,0 +1,2 @@
+from . import kit_config_wizard
+from . import kit_config_wizard_line
diff --git a/product_kit/wizard/kit_config_wizard.py b/product_kit/wizard/kit_config_wizard.py
new file mode 100644
index 00000000000..50d6c1bba16
--- /dev/null
+++ b/product_kit/wizard/kit_config_wizard.py
@@ -0,0 +1,111 @@
+from odoo import api, fields, models
+from odoo.tools.translate import _
+
+
+class KitConfigWizard(models.TransientModel):
+ _name = 'kit.config.wizard'
+ _description = 'Kit Configuration Wizard'
+
+ sale_order_id = fields.Many2one(
+ 'sale.order',
+ string="Sale Order",
+ required=True,
+ )
+ sale_line_id = fields.Many2one(
+ 'sale.order.line',
+ string="Sale Order Line",
+ required=True,
+ )
+
+ # Display fields
+ product_id = fields.Many2one(
+ 'product.product',
+ string="Product",
+ related='sale_line_id.product_id',
+ readonly=True,
+ )
+ product_image = fields.Binary(
+ string="Product Image",
+ related='sale_line_id.product_id.image_1920',
+ readonly=True,
+ )
+ product_name = fields.Char(
+ string="Product Name",
+ related='sale_line_id.product_id.display_name',
+ readonly=True,
+ )
+ order_name = fields.Char(
+ string="Order",
+ related='sale_order_id.name',
+ readonly=True,
+ )
+ kit_quantity = fields.Float(
+ string="Kit Quantity",
+ related='sale_line_id.product_uom_qty',
+ readonly=True,
+ )
+
+ line_ids = fields.One2many(
+ 'kit.config.wizard.line',
+ 'wizard_id',
+ string="Kit Lines",
+ )
+
+ @api.model
+ def default_get(self, fields_list):
+ res = super(KitConfigWizard, self).default_get(fields_list)
+ active_id = self.env.context.get('active_id')
+ sale_line_id = self.env.context.get('default_sale_line_id') or active_id
+
+ if sale_line_id:
+ line = self.env['sale.order.line'].browse(sale_line_id)
+ res.update({
+ 'sale_line_id': sale_line_id,
+ 'sale_order_id': line.order_id.id,
+ })
+
+ kit_template = line.product_id.product_tmpl_id
+ if kit_template.is_kit and kit_template.kit_product_ids:
+ kit_lines = [(0, 0, {
+ 'product_id': k.product_id.id,
+ 'quantity': k.quantity * line.product_uom_qty,
+ 'price_unit': k.product_id.lst_price,
+ }) for k in kit_template.kit_product_ids]
+ res['line_ids'] = kit_lines
+
+ return res
+
+ def action_confirm(self):
+ self.ensure_one()
+ line = self.sale_line_id
+
+ if line.kit_line_ids:
+ line.kit_line_ids.unlink()
+
+ section_seq = line.sequence + 1
+ section_line = self.env['sale.order.line'].create({
+ 'order_id': self.sale_order_id.id,
+ 'display_type': 'line_section',
+ 'name': _("Sub products of %s", line.name or line.product_id.display_name),
+ 'sequence': section_seq,
+ })
+
+ for idx, wizard_line in enumerate(self.line_ids):
+ vals = {
+ 'order_id': self.sale_order_id.id,
+ 'product_id': wizard_line.product_id.id,
+ 'product_uom_qty': wizard_line.quantity,
+ 'price_unit': wizard_line.price_unit,
+ 'name': wizard_line.product_id.display_name,
+ 'parent_kit_line_id': line.id,
+ 'sequence': section_seq + 1 + idx,
+ }
+ new_line = self.env['sale.order.line'].create(vals)
+ line.write({'kit_line_ids': [(4, new_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 {'type': 'ir.actions.act_window_close'}
diff --git a/product_kit/wizard/kit_config_wizard_line.py b/product_kit/wizard/kit_config_wizard_line.py
new file mode 100644
index 00000000000..d98cb105d0d
--- /dev/null
+++ b/product_kit/wizard/kit_config_wizard_line.py
@@ -0,0 +1,37 @@
+from odoo import api, fields, models
+
+
+class KitConfigWizardLine(models.TransientModel):
+ _name = 'kit.config.wizard.line'
+ _description = 'Kit Configuration Wizard Line'
+
+ wizard_id = fields.Many2one(
+ 'kit.config.wizard',
+ string="Wizard",
+ required=True,
+ ondelete='cascade',
+ )
+ product_id = fields.Many2one(
+ 'product.product',
+ string="Product",
+ required=True,
+ )
+ quantity = fields.Float(
+ string="Quantity",
+ required=True,
+ default=1.0,
+ )
+ price_unit = fields.Float(
+ string="Unit Price",
+ required=True,
+ )
+ subtotal = fields.Float(
+ string="Subtotal",
+ compute='_compute_subtotal',
+ store=True,
+ )
+
+ @api.depends('quantity', 'price_unit')
+ def _compute_subtotal(self):
+ for line in self:
+ line.subtotal = line.quantity * line.price_unit