From 39c6c9d595de9a154ef1afacb0bf21249c031dab Mon Sep 17 00:00:00 2001 From: times-odoo Date: Wed, 3 Jun 2026 14:51:54 +0530 Subject: [PATCH 1/5] [ADD] sale_kit_management: introduce dynamic sale kit product management and order configurator Implemented a product kit architecture allowing users to bundle sub-products, dynamically compute bundle pricing, and explode kit selections into sale orders. Rationale: Sales teams need a flexible mechanism to sell multi-component product bundles (kits) without manually tracking constituent items or distorting unit pricing structures. This module addresses that by introducing a kit layout toggle on products, establishing automatic cost roll-ups based on child line assignments, and integrating an interactive configuration wizard inside the sales quotation interface to generate dependent order lines dynamically. Technical choices: - Created 'product.subproduct.line' and 'sale.order.kit.config.line' models to store static templates and active configuration states respectively. - Overrode CRUD operations on subproduct lines to automatically recompute bundle prices using aggregate methods ('_compute_kit_list_price'). - Inherited 'sale.order.line' to establish tree cascading links ('kit_parent_line_id') and overrode 'write' and 'unlink' to mirror quantity scale changes or handle recursive removal adjustments automatically. - Designed a 'sale.order.kit' configuration wizard using 'default_get' to preload kit architectures and inject exploded subproduct items as discrete, protected, muted rows contextually attached beneath a section header block. - Applied multi-level list restrictions inside view templates using column-invisible and read-only flags to block explicit manual overrides of derived child row values. Task Reference: 6268133 --- sale_kit_management/__init__.py | 2 + sale_kit_management/__manifest__.py | 16 ++ sale_kit_management/models/__init__.py | 4 + .../models/product_subproduct_line.py | 29 ++++ .../models/product_template.py | 25 ++++ .../models/sale_order_kit_config_line.py | 11 ++ sale_kit_management/models/sale_order_line.py | 64 ++++++++ .../security/ir.model.access.csv | 6 + sale_kit_management/views/product_views.xml | 32 ++++ .../views/sale_order_views.xml | 63 ++++++++ sale_kit_management/wizard/__init__.py | 1 + sale_kit_management/wizard/sale_order_kit.py | 138 ++++++++++++++++++ .../wizard/sale_order_kit_views.xml | 49 +++++++ 13 files changed, 440 insertions(+) create mode 100644 sale_kit_management/__init__.py create mode 100644 sale_kit_management/__manifest__.py create mode 100644 sale_kit_management/models/__init__.py create mode 100644 sale_kit_management/models/product_subproduct_line.py create mode 100644 sale_kit_management/models/product_template.py create mode 100644 sale_kit_management/models/sale_order_kit_config_line.py create mode 100644 sale_kit_management/models/sale_order_line.py create mode 100644 sale_kit_management/security/ir.model.access.csv create mode 100644 sale_kit_management/views/product_views.xml create mode 100644 sale_kit_management/views/sale_order_views.xml create mode 100644 sale_kit_management/wizard/__init__.py create mode 100644 sale_kit_management/wizard/sale_order_kit.py create mode 100644 sale_kit_management/wizard/sale_order_kit_views.xml diff --git a/sale_kit_management/__init__.py b/sale_kit_management/__init__.py new file mode 100644 index 00000000000..9b4296142f4 --- /dev/null +++ b/sale_kit_management/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/sale_kit_management/__manifest__.py b/sale_kit_management/__manifest__.py new file mode 100644 index 00000000000..88797770afe --- /dev/null +++ b/sale_kit_management/__manifest__.py @@ -0,0 +1,16 @@ +{ + 'name': 'Sale Kit Management', + 'version': "1.0", + 'category': "Sales/Sales", + 'description': "Creates a Kit Option in Products and Sales Order", + 'depends': ['sale_management'], + 'data': [ + 'security/ir.model.access.csv', + 'wizard/sale_order_kit_views.xml', + 'views/product_views.xml', + 'views/sale_order_views.xml', + ], + 'installable': True, + 'author': "times", + 'license': "LGPL-3", +} diff --git a/sale_kit_management/models/__init__.py b/sale_kit_management/models/__init__.py new file mode 100644 index 00000000000..28136695f4f --- /dev/null +++ b/sale_kit_management/models/__init__.py @@ -0,0 +1,4 @@ +from . import product_template +from . import product_subproduct_line +from . import sale_order_line +from . import sale_order_kit_config_line diff --git a/sale_kit_management/models/product_subproduct_line.py b/sale_kit_management/models/product_subproduct_line.py new file mode 100644 index 00000000000..9e84a4255a0 --- /dev/null +++ b/sale_kit_management/models/product_subproduct_line.py @@ -0,0 +1,29 @@ +from odoo import api, fields, models + + +class ProductSubproductLine(models.Model): + _name = 'product.subproduct.line' + _description = "Subproducts" + + product_tmpl_id = fields.Many2one('product.template', ondelete='cascade') + product_id = fields.Many2one('product.product', string="Product") + product_uom_qty = fields.Float(related='product_id.qty_available', string="Quantity on Hand") + kit_unit_qty = fields.Float(string="Kit Unit Qty") + price_unit = fields.Float(related='product_id.lst_price', string="Unit Price") + + @api.model_create_multi + def create(self, vals_list): + lines = super().create(vals_list) + lines.mapped('product_tmpl_id')._compute_kit_list_price() + return lines + + def write(self, vals): + res = super().write(vals) + self.mapped('product_tmpl_id')._compute_kit_list_price() + return res + + def unlink(self): + templates = self.mapped('product_tmpl_id') + res = super().unlink() + templates._compute_kit_list_price() + return res diff --git a/sale_kit_management/models/product_template.py b/sale_kit_management/models/product_template.py new file mode 100644 index 00000000000..d2a2fa95f39 --- /dev/null +++ b/sale_kit_management/models/product_template.py @@ -0,0 +1,25 @@ +from odoo import api, fields, models + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + is_kit = fields.Boolean() + subproduct_ids = fields.One2many('product.subproduct.line', 'product_tmpl_id') + + @api.depends('is_kit', 'subproduct_ids.price_unit', 'subproduct_ids.kit_unit_qty') + def _compute_kit_list_price(self): + for product in self: + if product.is_kit and product.subproduct_ids: + product.list_price = sum( + sub.price_unit * sub.kit_unit_qty + for sub in product.subproduct_ids + ) + + @api.onchange('subproduct_ids') + def _onchange_subproduct_ids(self): + if self.is_kit and self.subproduct_ids: + self.list_price = sum( + sub.price_unit * sub.kit_unit_qty + for sub in self.subproduct_ids + ) diff --git a/sale_kit_management/models/sale_order_kit_config_line.py b/sale_kit_management/models/sale_order_kit_config_line.py new file mode 100644 index 00000000000..c778e67bef9 --- /dev/null +++ b/sale_kit_management/models/sale_order_kit_config_line.py @@ -0,0 +1,11 @@ +from odoo import fields, models + + +class SaleOrderKitConfigLine(models.Model): + _name = 'sale.order.kit.config.line' + _description = "Kit Configuration Line" + + sale_order_line_id = fields.Many2one('sale.order.line', string="Kit Parent Line", ondelete='cascade', required=True, index=True,) + product_id = fields.Many2one('product.product', string="Product", required=True) + kit_unit_qty = fields.Float(string="Kit Unit Qty", default=1.0) + price_unit = fields.Float(string="Unit Price") diff --git a/sale_kit_management/models/sale_order_line.py b/sale_kit_management/models/sale_order_line.py new file mode 100644 index 00000000000..48f7109769a --- /dev/null +++ b/sale_kit_management/models/sale_order_line.py @@ -0,0 +1,64 @@ +from odoo import _, api, fields, models + + +class SaleOrderLine(models.Model): + _inherit = 'sale.order.line' + + is_kit = fields.Boolean(related='product_id.product_tmpl_id.is_kit') + is_kit_subproduct = fields.Boolean(string="Is Kit Subproduct", default=False) + kit_unit_qty = fields.Float(string="Kit Unit Qty") + kit_parent_line_id = fields.Many2one('sale.order.line', string="Kit Parent Line", ondelete='cascade', index=True,) + has_kit_subproducts = fields.Boolean(compute='_compute_has_kit_subproducts') + kit_config_line_ids = fields.One2many('sale.order.kit.config.line', 'sale_order_line_id', string="Kit Configuration") + + @api.depends('order_id.order_line.kit_parent_line_id') + def _compute_has_kit_subproducts(self): + for line in self: + if line.is_kit and line.id: + line.has_kit_subproducts = any( + l.kit_parent_line_id.id == line.id + for l in line.order_id.order_line + if l.is_kit_subproduct + ) + else: + line.has_kit_subproducts = False + + def _compute_kit_price(self): + for line in self: + if line.is_kit and line.kit_config_line_ids: + line.price_unit = sum( + cl.price_unit * cl.product_uom_qty + for cl in line.kit_config_line_ids + ) + + def write(self, vals): + result = super().write(vals) + if 'product_uom_qty' in vals: + for line in self.filtered(lambda l: l.is_kit and l.id): + child_lines = self.env['sale.order.line'].search([ + ('kit_parent_line_id', '=', line.id), + ('is_kit_subproduct', '=', True), + ]) + for child in child_lines: + if child.kit_unit_qty: + child.product_uom_qty = line.product_uom_qty * child.kit_unit_qty + return result + + def unlink(self): + child_lines = self.env['sale.order.line'].search([ + ('kit_parent_line_id', 'in', self.ids) + ]) + if child_lines: + child_lines.unlink() + return super().unlink() + + def action_open_kit_configurator(self): + self.ensure_one() + return { + 'name': _("Kit"), + 'type': 'ir.actions.act_window', + 'res_model': 'sale.order.kit', + 'view_mode': 'form', + 'target': 'new', + 'context': {'default_sale_order_line_id': self.id}, + } diff --git a/sale_kit_management/security/ir.model.access.csv b/sale_kit_management/security/ir.model.access.csv new file mode 100644 index 00000000000..9f4e0029266 --- /dev/null +++ b/sale_kit_management/security/ir.model.access.csv @@ -0,0 +1,6 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_product_subproduct_line,access_product_subproduct_line,model_product_subproduct_line,sales_team.group_sale_salesman,1,1,1,1 +access_sale_order_kit,access_sale_order_kit,model_sale_order_kit,sales_team.group_sale_salesman,1,1,1,1 +access_sale_order_kit_line,access_sale_order_kit_line,model_sale_order_kit_line,sales_team.group_sale_salesman,1,1,1,1 +access_sale_order_kit_config_line,access_sale_order_kit_config_line,model_sale_order_kit_config_line,sales_team.group_sale_salesman,1,1,1,1 + diff --git a/sale_kit_management/views/product_views.xml b/sale_kit_management/views/product_views.xml new file mode 100644 index 00000000000..3a97936e4db --- /dev/null +++ b/sale_kit_management/views/product_views.xml @@ -0,0 +1,32 @@ + + + + product.template.form.kit + product.template + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sale_kit_management/views/sale_order_views.xml b/sale_kit_management/views/sale_order_views.xml new file mode 100644 index 00000000000..77fb0c77d24 --- /dev/null +++ b/sale_kit_management/views/sale_order_views.xml @@ -0,0 +1,63 @@ + + + + sale.order.form.kit + sale.order + + + + +