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 + + + + +