From 05b34a14324c902e170440762a687a42dd795824 Mon Sep 17 00:00:00 2001 From: aykhu-odoo Date: Wed, 3 Jun 2026 11:39:57 +0530 Subject: [PATCH] [ADD] sale_product_kit: introduce configurable kit products Some products are sold as a single item but consist of multiple components whose quantities and prices may vary from one order to another. Managing these components as regular sale order lines makes it difficult to keep a single commercial price while still retaining the component details required for stock operations. This module introduces a kit product type with configurable sub-products. When a kit is added to a sale order, users can configure the quantity and pricing of each component through a dedicated wizard. The kit is represented by a parent sale order line, while its components are stored as linked child lines. Component prices are aggregated on the parent line, whereas child lines are created with a zero sales price and are retained to generate the required stock moves. To keep kit data consistent, component lines cannot be modified directly from the sale order. Changes must be performed through the kit configuration wizard, ensuring that parent and child line values remain synchronized. Different businesses may prefer to expose either the kit breakdown or only the final commercial product to customers. A print option is added on the sale order to control whether kit components are displayed on sale order, portal, and invoice reports. --- sale_product_kit/__init__.py | 2 + sale_product_kit/__manifest__.py | 17 ++++ sale_product_kit/models/__init__.py | 4 + sale_product_kit/models/account_move.py | 14 ++++ sale_product_kit/models/product_template.py | 15 ++++ sale_product_kit/models/sale_order.py | 16 ++++ sale_product_kit/models/sale_order_line.py | 17 ++++ sale_product_kit/security/ir.model.access.csv | 3 + .../src/js/product_kit_sale_order_line.js | 47 +++++++++++ sale_product_kit/views/product_views.xml | 17 ++++ sale_product_kit/views/sale_order_views.xml | 36 ++++++++ sale_product_kit/wizard/__init__.py | 1 + sale_product_kit/wizard/product_kit_wizard.py | 84 +++++++++++++++++++ .../wizard/product_kit_wizard_views.xml | 24 ++++++ 14 files changed, 297 insertions(+) create mode 100644 sale_product_kit/__init__.py create mode 100644 sale_product_kit/__manifest__.py create mode 100644 sale_product_kit/models/__init__.py create mode 100644 sale_product_kit/models/account_move.py create mode 100644 sale_product_kit/models/product_template.py create mode 100644 sale_product_kit/models/sale_order.py create mode 100644 sale_product_kit/models/sale_order_line.py create mode 100644 sale_product_kit/security/ir.model.access.csv create mode 100644 sale_product_kit/static/src/js/product_kit_sale_order_line.js create mode 100644 sale_product_kit/views/product_views.xml create mode 100644 sale_product_kit/views/sale_order_views.xml create mode 100644 sale_product_kit/wizard/__init__.py create mode 100644 sale_product_kit/wizard/product_kit_wizard.py create mode 100644 sale_product_kit/wizard/product_kit_wizard_views.xml 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 + + + + + + + + +