diff --git a/sales_products_kit/__init__.py b/sales_products_kit/__init__.py
new file mode 100644
index 00000000000..9b4296142f4
--- /dev/null
+++ b/sales_products_kit/__init__.py
@@ -0,0 +1,2 @@
+from . import models
+from . import wizard
diff --git a/sales_products_kit/__manifest__.py b/sales_products_kit/__manifest__.py
new file mode 100644
index 00000000000..62826bbda9c
--- /dev/null
+++ b/sales_products_kit/__manifest__.py
@@ -0,0 +1,21 @@
+{
+ "name": "Sales - Product_Kit",
+ "version": "1.0",
+ "description": """
+ This custom module adds a function to Odoo to sell products as a Kit, but not using a BOM or the Manufacturing Module.
+ """,
+ "category": "Sales/Sales",
+ "depends": ["sale_management"],
+ "data": [
+ "security/ir.model.access.csv",
+ "wizard/sub_product_wizard.xml",
+ "views/product_view.xml",
+ "views/sale_order_view.xml",
+ "report/sale_order_report.xml",
+ "views/sale_portal_templates.xml",
+ "report/invoice_report.xml",
+ ],
+ "installable": True,
+ "auto_install": False,
+ "license": "LGPL-3",
+}
diff --git a/sales_products_kit/models/__init__.py b/sales_products_kit/models/__init__.py
new file mode 100644
index 00000000000..8f2f8c0cbc1
--- /dev/null
+++ b/sales_products_kit/models/__init__.py
@@ -0,0 +1,3 @@
+from . import product_template
+from . import sale_order_line
+from . import sale_order
diff --git a/sales_products_kit/models/product_template.py b/sales_products_kit/models/product_template.py
new file mode 100644
index 00000000000..3051e74b5b8
--- /dev/null
+++ b/sales_products_kit/models/product_template.py
@@ -0,0 +1,8 @@
+from odoo import fields, models
+
+
+class ProductTemplate(models.Model):
+ _inherit = "product.template"
+
+ is_kit = fields.Boolean()
+ sub_product_ids = fields.Many2many("product.product", string="Sub Product")
diff --git a/sales_products_kit/models/sale_order.py b/sales_products_kit/models/sale_order.py
new file mode 100644
index 00000000000..191177cae3c
--- /dev/null
+++ b/sales_products_kit/models/sale_order.py
@@ -0,0 +1,7 @@
+from odoo import models, fields
+
+
+class SaleOrder(models.Model):
+ _inherit = "sale.order"
+
+ print_in_report = fields.Boolean(string="Print in Report ?")
diff --git a/sales_products_kit/models/sale_order_line.py b/sales_products_kit/models/sale_order_line.py
new file mode 100644
index 00000000000..fd41a542790
--- /dev/null
+++ b/sales_products_kit/models/sale_order_line.py
@@ -0,0 +1,51 @@
+from odoo import models, fields, api
+from odoo.exceptions import UserError
+
+
+class SaleOrderLine(models.Model):
+ _inherit = "sale.order.line"
+
+ is_kit = fields.Boolean(related="product_template_id.is_kit")
+ parent_line_id = fields.Many2one("sale.order.line", ondelete="cascade")
+
+ @api.ondelete(at_uninstall=False)
+ def _ondelete_sale_order_line(self):
+ if not self.parent_line_id:
+ for line in self:
+ sub_product_lines = self.env["sale.order.line"].search(
+ [("parent_line_id", "=", line.id)]
+ )
+ sub_product_lines.with_context(allow_child_unlink=True).unlink()
+ else:
+ if not self.env.context.get("allow_child_unlink"):
+ raise UserError(
+ "You cannot delete a child line directly. Please delete the parent line instead."
+ )
+
+ def write(self, vals):
+ if "product_uom_qty" in vals and not self.parent_line_id:
+ for line in self:
+ sub_product_lines = self.env["sale.order.line"].search(
+ [("parent_line_id", "=", line.id)]
+ )
+ old_qty = line.product_uom_qty
+ for sub_line in sub_product_lines:
+ if sub_line:
+ if old_qty != 0:
+ qty = sub_line.product_uom_qty / old_qty
+ new_qty = vals["product_uom_qty"] * qty
+ sub_line.update({"product_uom_qty": new_qty})
+ else:
+ new_qty = vals["product_uom_qty"] * sub_line.product_uom_qty
+ sub_line.update({"product_uom_qty": new_qty})
+ return super().write(vals)
+
+ def action_subproduct(self):
+ return {
+ "type": "ir.actions.act_window",
+ "name": f"Product: {self.product_id.name}",
+ "res_model": "sub.product.wizard",
+ "view_mode": "form",
+ "target": "new",
+ "context": {"default_sale_order_line_id": self.id},
+ }
diff --git a/sales_products_kit/report/invoice_report.xml b/sales_products_kit/report/invoice_report.xml
new file mode 100644
index 00000000000..60b58cc6ec5
--- /dev/null
+++ b/sales_products_kit/report/invoice_report.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+ line.sale_line_ids.order_id.print_in_report or (not line.sale_line_ids.parent_line_id)
+
+
+
+
diff --git a/sales_products_kit/report/sale_order_report.xml b/sales_products_kit/report/sale_order_report.xml
new file mode 100644
index 00000000000..a3f242f5350
--- /dev/null
+++ b/sales_products_kit/report/sale_order_report.xml
@@ -0,0 +1,8 @@
+
+
+
+
+ doc.print_in_report or (not line.parent_line_id)
+
+
+
diff --git a/sales_products_kit/security/ir.model.access.csv b/sales_products_kit/security/ir.model.access.csv
new file mode 100644
index 00000000000..c57a11b5dbd
--- /dev/null
+++ b/sales_products_kit/security/ir.model.access.csv
@@ -0,0 +1,3 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_sub_product_wizard,access_sub_product_wizard,model_sub_product_wizard,base.group_user,1,1,1,1
+access_sub_product_line,access_sub_product_line,model_sub_product_line,base.group_user,1,1,1,1
diff --git a/sales_products_kit/tests/__init__.py b/sales_products_kit/tests/__init__.py
new file mode 100644
index 00000000000..416fc1a5206
--- /dev/null
+++ b/sales_products_kit/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_subproduct
diff --git a/sales_products_kit/tests/test_subproduct.py b/sales_products_kit/tests/test_subproduct.py
new file mode 100644
index 00000000000..bc3d0a8b600
--- /dev/null
+++ b/sales_products_kit/tests/test_subproduct.py
@@ -0,0 +1,78 @@
+from odoo.tests import TransactionCase
+
+
+class TestSubProdcut(TransactionCase):
+ def setUp(self):
+ childproduct1 = self.env["product.template"].create({"name": "Child1"})
+ childproduct2 = self.env["product.template"].create({"name": "Child2"})
+ self.product1 = self.env["product.product"].search(
+ [("product_tmpl_id", "=", childproduct1.id)]
+ )
+ self.product2 = self.env["product.product"].search(
+ [("product_tmpl_id", "=", childproduct2.id)]
+ )
+
+ parentproduct = self.env["product.template"].create(
+ {
+ "name": "parent",
+ "is_kit": True,
+ "sub_product_ids": [self.product1.id, self.product2.id],
+ }
+ )
+ self.parent_product = self.env["product.product"].search(
+ [("product_tmpl_id", "=", parentproduct.id)]
+ )
+
+ sale_order = self.env["sale.order"].create(
+ {
+ "partner_id": self.env.ref("base.partner_demo").id,
+ }
+ )
+ self.parentline = self.env["sale.order.line"].create(
+ {
+ "product_id": self.parent_product.id,
+ "order_id": sale_order.id,
+ "product_uom_qty": 1,
+ "name": self.parent_product.name,
+ }
+ )
+ self.childline1 = self.env["sale.order.line"].create(
+ {
+ "product_template_id": self.product1.id,
+ "order_id": sale_order.id,
+ "product_uom_qty": 3,
+ "parent_line_id": self.parentline.id,
+ "name": self.product1.name,
+ }
+ )
+ self.childline2 = self.env["sale.order.line"].create(
+ {
+ "product_template_id": self.product2.id,
+ "order_id": sale_order.id,
+ "product_uom_qty": 2,
+ "parent_line_id": self.parentline.id,
+ "name": self.product2.name,
+ }
+ )
+
+ def test_parent_quantity_update_child(self):
+ old_parent_qty = self.parentline.product_uom_qty
+ old_child_qty1 = self.childline1.product_uom_qty
+ old_child_qty2 = self.childline2.product_uom_qty
+
+ new_parent_qty = 4
+ self.parentline.write({"product_uom_qty": new_parent_qty})
+
+ new_child_qty1 = (old_child_qty1 / old_parent_qty) * new_parent_qty
+ new_child_qty2 = (old_child_qty2 / old_parent_qty) * new_parent_qty
+
+ self.assertEqual(
+ self.childline1.product_uom_qty,
+ new_child_qty1,
+ f"expected {new_child_qty1}, but got {self.childline1.product_uom_qty}",
+ )
+ self.assertEqual(
+ self.childline2.product_uom_qty,
+ new_child_qty2,
+ f"expected {new_child_qty2}, but got {self.childline2.product_uom_qty}",
+ )
diff --git a/sales_products_kit/views/product_view.xml b/sales_products_kit/views/product_view.xml
new file mode 100644
index 00000000000..9addc0aa6ad
--- /dev/null
+++ b/sales_products_kit/views/product_view.xml
@@ -0,0 +1,15 @@
+
+
+
+
+ product.template.form.view.inherit.sales_products_kit
+ product.template
+
+
+
+
+
+
+
+
+
diff --git a/sales_products_kit/views/sale_order_view.xml b/sales_products_kit/views/sale_order_view.xml
new file mode 100644
index 00000000000..548dfa53d1d
--- /dev/null
+++ b/sales_products_kit/views/sale_order_view.xml
@@ -0,0 +1,45 @@
+
+
+
+
+ sale.order.form.inherit
+ sale.order
+
+
+
+
+
+
+
+
+
+
+
+ parent_line_id
+
+
+ parent_line_id
+
+
+ parent_line_id
+
+
+ parent_line_id
+
+
+ parent_line_id
+
+
+ parent_line_id
+
+
+
+
+
diff --git a/sales_products_kit/views/sale_portal_templates.xml b/sales_products_kit/views/sale_portal_templates.xml
new file mode 100644
index 00000000000..95586c26b36
--- /dev/null
+++ b/sales_products_kit/views/sale_portal_templates.xml
@@ -0,0 +1,8 @@
+
+
+
+
+ sale_order.print_in_report or (not line.parent_line_id)
+
+
+
diff --git a/sales_products_kit/wizard/__init__.py b/sales_products_kit/wizard/__init__.py
new file mode 100644
index 00000000000..70e78c5398b
--- /dev/null
+++ b/sales_products_kit/wizard/__init__.py
@@ -0,0 +1,2 @@
+from . import sub_product_wizard
+from . import sub_product_line
diff --git a/sales_products_kit/wizard/sub_product_line.py b/sales_products_kit/wizard/sub_product_line.py
new file mode 100644
index 00000000000..a70e3ab2624
--- /dev/null
+++ b/sales_products_kit/wizard/sub_product_line.py
@@ -0,0 +1,15 @@
+from odoo import fields, models
+
+
+class SubProductLine(models.TransientModel):
+ _name = "sub.product.line"
+ _description = (
+ "A transient model representing a line item for sub-products in a wizard."
+ )
+
+ product_id = fields.Many2one("product.product", required=True, ondelete="cascade")
+ quantity = fields.Integer(string="Quantity", required=True, default=1)
+ price_unit = fields.Float(string="Unit Price", required=True)
+ sub_product_wizard_id = fields.Many2one(
+ "sub.product.wizard", required=True, ondelete="cascade"
+ )
diff --git a/sales_products_kit/wizard/sub_product_wizard.py b/sales_products_kit/wizard/sub_product_wizard.py
new file mode 100644
index 00000000000..05b9ecfa81a
--- /dev/null
+++ b/sales_products_kit/wizard/sub_product_wizard.py
@@ -0,0 +1,96 @@
+from odoo import fields, models, api, Command
+from odoo.exceptions import UserError
+
+
+class SubProductWizard(models.TransientModel):
+ _name = "sub.product.wizard"
+ _description = "A wizard the display sub-product included in the kit."
+
+ sale_order_line_id = fields.Many2one(
+ "sale.order.line", required=True, ondelete="cascade"
+ )
+ line_ids = fields.One2many(
+ comodel_name="sub.product.line",
+ inverse_name="sub_product_wizard_id",
+ string="Sub Products",
+ required=True,
+ )
+
+ @api.model
+ def default_get(self, fields):
+ defaults = super().default_get(fields)
+ sale_order_line_id = self.env.context.get("default_sale_order_line_id")
+ sale_order_line = self.env["sale.order.line"].browse(sale_order_line_id)
+ product_template = sale_order_line.product_template_id
+ existing_wizard = self.env["sub.product.wizard"].search(
+ [("sale_order_line_id", "=", sale_order_line_id)]
+ )
+
+ if existing_wizard:
+ defaults.update(
+ {
+ "sale_order_line_id": sale_order_line_id,
+ "line_ids": existing_wizard.line_ids,
+ }
+ )
+ else:
+ line_data = []
+ for sub_product in product_template.sub_product_ids:
+ line_data.append(
+ Command.create(
+ {
+ "product_id": sub_product.id,
+ "quantity": 1,
+ "price_unit": sub_product.lst_price,
+ }
+ )
+ )
+
+ defaults.update(
+ {
+ "sale_order_line_id": sale_order_line_id,
+ "line_ids": line_data,
+ }
+ )
+ return defaults
+
+ def action_confirm(self):
+ if sum(self.line_ids.mapped("quantity")) == 0:
+ raise UserError(
+ "You must select atleast 1 sub-product to purchase the kit product."
+ )
+
+ lines_to_create = []
+ total_price = 0
+
+ for line in self.line_ids:
+ if line.quantity > 0:
+ existing_line = self.env["sale.order.line"].search(
+ [
+ ("order_id", "=", self.sale_order_line_id.order_id.id),
+ ("product_id", "=", line.product_id.id),
+ ("parent_line_id", "=", self.sale_order_line_id.id),
+ ],
+ limit=1,
+ )
+
+ if existing_line:
+ existing_line.product_uom_qty = line.quantity
+ existing_line.price_unit = 0.0
+ else:
+ lines_to_create.append(
+ {
+ "order_id": self.sale_order_line_id.order_id.id,
+ "product_id": line.product_id.id,
+ "product_uom_qty": line.quantity,
+ "price_unit": 0.0,
+ "parent_line_id": self.sale_order_line_id.id,
+ }
+ )
+ total_price += line.quantity * line.price_unit
+
+ if lines_to_create:
+ self.env["sale.order.line"].create(lines_to_create)
+
+ self.sale_order_line_id.price_unit = total_price
+ return True
diff --git a/sales_products_kit/wizard/sub_product_wizard.xml b/sales_products_kit/wizard/sub_product_wizard.xml
new file mode 100644
index 00000000000..fa2e16f3d6a
--- /dev/null
+++ b/sales_products_kit/wizard/sub_product_wizard.xml
@@ -0,0 +1,26 @@
+
+
+
+ sub.product.wizard.form.view
+ sub.product.wizard
+
+
+
+
+