diff --git a/sale_product_kit/__init__.py b/sale_product_kit/__init__.py
new file mode 100644
index 00000000000..9b4296142f4
--- /dev/null
+++ b/sale_product_kit/__init__.py
@@ -0,0 +1,2 @@
+from . import models
+from . import wizard
diff --git a/sale_product_kit/__manifest__.py b/sale_product_kit/__manifest__.py
new file mode 100644
index 00000000000..c515c585078
--- /dev/null
+++ b/sale_product_kit/__manifest__.py
@@ -0,0 +1,17 @@
+{
+ 'name': 'Product Kit',
+ 'author': 'Ayush Khubchandani (aykhu)',
+ 'license': 'LGPL-3',
+ 'depends': ["sale", "product"],
+ 'data': [
+ 'security/ir.model.access.csv',
+ 'views/product_views.xml',
+ 'views/sale_order_views.xml',
+ 'wizard/product_kit_wizard_views.xml',
+ ],
+ 'assets': {
+ 'web.assets_backend': [
+ 'sale_product_kit/static/src/js/product_kit_sale_order_line.js',
+ ],
+ },
+}
diff --git a/sale_product_kit/models/__init__.py b/sale_product_kit/models/__init__.py
new file mode 100644
index 00000000000..fbe8e5e1443
--- /dev/null
+++ b/sale_product_kit/models/__init__.py
@@ -0,0 +1,4 @@
+from . import product_template
+from . import sale_order_line
+from . import sale_order
+from . import account_move
diff --git a/sale_product_kit/models/account_move.py b/sale_product_kit/models/account_move.py
new file mode 100644
index 00000000000..072cd44b239
--- /dev/null
+++ b/sale_product_kit/models/account_move.py
@@ -0,0 +1,14 @@
+from odoo import models
+
+
+class AccountMove(models.Model):
+ _inherit = 'account.move'
+
+ def _get_move_lines_to_report(self):
+ lines = super()._get_move_lines_to_report()
+ return lines.filtered(
+ lambda l: (
+ not l.sale_line_ids.is_kit_child
+ or l.sale_line_ids.order_id.print_kit_components
+ )
+ )
diff --git a/sale_product_kit/models/product_template.py b/sale_product_kit/models/product_template.py
new file mode 100644
index 00000000000..40897ab24b1
--- /dev/null
+++ b/sale_product_kit/models/product_template.py
@@ -0,0 +1,15 @@
+from odoo import api, fields, models
+from odoo.exceptions import ValidationError
+
+
+class ProductTemplate(models.Model):
+ _inherit = "product.template"
+
+ is_kit = fields.Boolean()
+ sub_product_ids = fields.Many2many('product.product')
+
+ @api.constrains("sub_product_ids")
+ def _check_no_self_product_reference(self):
+ for record in self:
+ if record.product_variant_id in record.sub_product_ids:
+ raise ValidationError("A product cannot be added as a sub-product in its own kit.")
diff --git a/sale_product_kit/models/sale_order.py b/sale_product_kit/models/sale_order.py
new file mode 100644
index 00000000000..0fd95dff0bb
--- /dev/null
+++ b/sale_product_kit/models/sale_order.py
@@ -0,0 +1,16 @@
+from odoo import fields, models
+
+
+class SaleOrder(models.Model):
+ _inherit = 'sale.order'
+
+ print_kit_components = fields.Boolean()
+
+ def _get_order_lines_to_report(self):
+ lines = super()._get_order_lines_to_report()
+ return lines.filtered(
+ lambda l: (
+ not l.is_kit_child
+ or l.order_id.print_kit_components
+ )
+ )
diff --git a/sale_product_kit/models/sale_order_line.py b/sale_product_kit/models/sale_order_line.py
new file mode 100644
index 00000000000..80289bd21f4
--- /dev/null
+++ b/sale_product_kit/models/sale_order_line.py
@@ -0,0 +1,17 @@
+from odoo import fields, models
+
+
+class SaleOrderLine(models.Model):
+ _inherit = "sale.order.line"
+
+ is_kit = fields.Boolean(related='product_template_id.is_kit')
+ parent_kit_line_id = fields.Many2one(
+ "sale.order.line",
+ ondelete="cascade",
+ )
+ child_kit_line_ids = fields.One2many(
+ "sale.order.line",
+ "parent_kit_line_id",
+ )
+ is_kit_child = fields.Boolean()
+ kit_component_price = fields.Float()
diff --git a/sale_product_kit/security/ir.model.access.csv b/sale_product_kit/security/ir.model.access.csv
new file mode 100644
index 00000000000..1a7a4f167f2
--- /dev/null
+++ b/sale_product_kit/security/ir.model.access.csv
@@ -0,0 +1,3 @@
+id,name,model_id:id,group_id:id,perm_read,perm_create,perm_write,perm_unlink
+access_product_kit_wizard,access_product_kit_wizard,model_product_kit_wizard,base.group_user,1,1,1,1
+access_product_kit_wizard_line,access_product_kit_wizard_line,model_product_kit_wizard_line,base.group_user,1,1,1,1
diff --git a/sale_product_kit/static/src/js/product_kit_sale_order_line.js b/sale_product_kit/static/src/js/product_kit_sale_order_line.js
new file mode 100644
index 00000000000..45c7c1aea1c
--- /dev/null
+++ b/sale_product_kit/static/src/js/product_kit_sale_order_line.js
@@ -0,0 +1,47 @@
+import { registry } from "@web/core/registry";
+import {
+ SaleOrderLineListRenderer,
+ SaleOrderLineOne2Many,
+} from "@sale/js/sale_order_line_field/sale_order_line_field";
+
+
+export class ProductKitSaleOrderLineListRenderer extends SaleOrderLineListRenderer {
+
+ isKitChild(record) {
+ return !!record.data.is_kit_child;
+ }
+
+ isKitParent(record){
+ return !!record.data.is_kit;
+ }
+
+ isCellReadonly(column, record) {
+ return super.isCellReadonly(column, record) || (
+ this.isKitChild(record)
+ ) || (
+ this.isKitParent(record)
+ );
+ }
+
+ displayDeleteIcon(record) {
+ return super.displayDeleteIcon(record)
+ && !this.isKitChild(record);
+ }
+}
+
+export class ProductKitSaleOrderLineOne2Many extends SaleOrderLineOne2Many {
+ static components = {
+ ...SaleOrderLineOne2Many.components,
+ ListRenderer: ProductKitSaleOrderLineListRenderer,
+ };
+}
+
+export const productKitSaleOrderLineOne2Many = {
+ ...registry.category("fields").get("sol_o2m"),
+ component: ProductKitSaleOrderLineOne2Many,
+};
+
+registry.category("fields").add(
+ "product_kit_sol_o2m",
+ productKitSaleOrderLineOne2Many
+);
diff --git a/sale_product_kit/views/product_views.xml b/sale_product_kit/views/product_views.xml
new file mode 100644
index 00000000000..7b884c67352
--- /dev/null
+++ b/sale_product_kit/views/product_views.xml
@@ -0,0 +1,17 @@
+
+
+
+
+ product.kit.form.inherit.product.template
+ product.template
+ 5
+
+
+
+
+
+
+
+
+
+
diff --git a/sale_product_kit/views/sale_order_views.xml b/sale_product_kit/views/sale_order_views.xml
new file mode 100644
index 00000000000..792e15c7227
--- /dev/null
+++ b/sale_product_kit/views/sale_order_views.xml
@@ -0,0 +1,36 @@
+
+
+
+ Product Kit Values
+ product.kit.wizard
+ form
+ new
+
+
+
+ sale.order.form.product.kit
+ sale.order
+
+
+
+ product_kit_sol_o2m
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sale_product_kit/wizard/__init__.py b/sale_product_kit/wizard/__init__.py
new file mode 100644
index 00000000000..cc98822aa2b
--- /dev/null
+++ b/sale_product_kit/wizard/__init__.py
@@ -0,0 +1 @@
+from . import product_kit_wizard
diff --git a/sale_product_kit/wizard/product_kit_wizard.py b/sale_product_kit/wizard/product_kit_wizard.py
new file mode 100644
index 00000000000..cd78aba0fa1
--- /dev/null
+++ b/sale_product_kit/wizard/product_kit_wizard.py
@@ -0,0 +1,84 @@
+from odoo import api, fields, models
+
+
+class ProductKitWizard(models.TransientModel):
+ _name = "product.kit.wizard"
+ _description = "Product Kit Wizard"
+
+ @api.model
+ def default_get(self, fields_list):
+ res = super().default_get(fields_list)
+ order_line = self.env['sale.order.line'].browse(
+ self.env.context.get('active_order_line_id')
+ )
+ if order_line.child_kit_line_ids:
+ wizard_lines = [
+ (0, 0, {
+ 'sub_product_id': line.product_id.id,
+ 'quantity': line.product_uom_qty,
+ 'price': line.kit_component_price,
+ })
+ for line in order_line.child_kit_line_ids
+ ]
+ else:
+ wizard_lines = [
+ (0, 0, {
+ 'sub_product_id': sp.id,
+ 'quantity': 1,
+ 'price': sp.lst_price,
+ })
+ for sp in order_line.product_template_id.sub_product_ids
+ ]
+ res.update({
+ 'product_id': order_line.product_id.id,
+ 'wizard_line_ids': wizard_lines,
+ })
+ return res
+
+ product_id = fields.Many2one('product.product')
+ wizard_line_ids = fields.One2many("product.kit.wizard.line", "wizard_id")
+
+ def add_sub_product_value(self):
+ parent_line = self.env['sale.order.line'].browse(
+ self.env.context.get('active_order_line_id')
+ )
+ total_price = 0
+ existing_children = {
+ line.product_id.id: line
+ for line in parent_line.child_kit_line_ids
+ }
+ create_vals = []
+ for wizard_line in self.wizard_line_ids:
+ total_price = total_price + (wizard_line.price * wizard_line.quantity)
+ product_id = wizard_line.sub_product_id.id
+ existing_child = existing_children.get(product_id)
+ if existing_child:
+ existing_child.write({
+ 'product_uom_qty': wizard_line.quantity,
+ 'price_unit': 0,
+ 'kit_component_price': wizard_line.price,
+ })
+ else:
+ create_vals.append({
+ 'order_id': parent_line.order_id.id,
+ 'product_id': product_id,
+ 'product_uom_qty': wizard_line.quantity,
+ 'kit_component_price': wizard_line.price,
+ 'price_unit': 0,
+ 'parent_kit_line_id': parent_line.id,
+ 'is_kit_child': True,
+ })
+ if create_vals:
+ self.env['sale.order.line'].create(create_vals)
+ parent_line.price_unit = total_price
+ return True
+
+
+class ProductKitWizardLine(models.TransientModel):
+ _name = 'product.kit.wizard.line'
+ _description = 'Product Kit Wizard Lines'
+
+ wizard_id = fields.Many2one('product.kit.wizard')
+ sub_product_id = fields.Many2one('product.product')
+ quantity = fields.Integer(default=1)
+ price = fields.Float()
diff --git a/sale_product_kit/wizard/product_kit_wizard_views.xml b/sale_product_kit/wizard/product_kit_wizard_views.xml
new file mode 100644
index 00000000000..ec2cda4ff9a
--- /dev/null
+++ b/sale_product_kit/wizard/product_kit_wizard_views.xml
@@ -0,0 +1,24 @@
+
+
+
+
+ product.kit.wizard.form
+ product.kit.wizard
+
+
+
+
+
+