diff --git a/product_warranty/__init__.py b/product_warranty/__init__.py new file mode 100644 index 00000000000..33bbab569d0 --- /dev/null +++ b/product_warranty/__init__.py @@ -0,0 +1,4 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models +from . import wizard diff --git a/product_warranty/__manifest__.py b/product_warranty/__manifest__.py new file mode 100644 index 00000000000..e7854c8ce36 --- /dev/null +++ b/product_warranty/__manifest__.py @@ -0,0 +1,22 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + "name": "Warranty Management for Products", + "version": "1.0", + "category": "Sales", + "summary": "Manage product warranties", + "description": """ + This module allows you to manage product warranties, including warranty periods and conditions. + """, + "depends": ["sale_management"], + "data": [ + "security/ir.model.access.csv", + "views/product_warranty.xml", + "views/product_warranty_config_views.xml", + "views/product_warranty_config_menus.xml", + "views/sale_order_views.xml", + "wizard/product_warranty_wizard.xml", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/product_warranty/models/__init__.py b/product_warranty/models/__init__.py new file mode 100644 index 00000000000..aa7bd366bc7 --- /dev/null +++ b/product_warranty/models/__init__.py @@ -0,0 +1,6 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import product_warranty +from . import product_warranty_config +from . import sale_order +from . import sale_order_line diff --git a/product_warranty/models/product_warranty.py b/product_warranty/models/product_warranty.py new file mode 100644 index 00000000000..6fab05deafa --- /dev/null +++ b/product_warranty/models/product_warranty.py @@ -0,0 +1,9 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + warranty = fields.Boolean(string="Warranty", help="Is warranty available for this product?") diff --git a/product_warranty/models/product_warranty_config.py b/product_warranty/models/product_warranty_config.py new file mode 100644 index 00000000000..eb9f049543e --- /dev/null +++ b/product_warranty/models/product_warranty_config.py @@ -0,0 +1,19 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ProductWarrantyConfig(models.Model): + _name = "product.warranty.config" + _description = "Product Warranty Configuration" + + name = fields.Char(string="Warranty Name", required=True) + percentage = fields.Float(string="Percentage", help="Warranty percentage") + year = fields.Integer( + string="Warranty Period (Year)", required=True, + help="Warranty period in years" + ) + product_id = fields.Many2one( + comodel_name="product.product", string="Product", + help="Warranty product name" + ) diff --git a/product_warranty/models/sale_order.py b/product_warranty/models/sale_order.py new file mode 100644 index 00000000000..6d3946e0251 --- /dev/null +++ b/product_warranty/models/sale_order.py @@ -0,0 +1,28 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + has_warranty_product = fields.Boolean( + string="Has Warranty Product", store=True, + compute='_compute_has_warranty_product' + ) + + @api.depends('order_line.product_id') + def _compute_has_warranty_product(self): + for order in self: + order.has_warranty_product = any(line.product_id.warranty for line in order.order_line) + + def open_add_warranty_wizard(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': 'Add Warranty Products', + 'res_model': 'product.warranty.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': {'default_sale_order_id': self.id} + } diff --git a/product_warranty/models/sale_order_line.py b/product_warranty/models/sale_order_line.py new file mode 100644 index 00000000000..6dbdc6bbbc0 --- /dev/null +++ b/product_warranty/models/sale_order_line.py @@ -0,0 +1,32 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + source_order_line_id = fields.Many2one( + comodel_name="sale.order.line", + string="Source Order Line", ondelete="cascade", + ) + + def write(self, vals): + res = super().write(vals) + if "price_unit" in vals: + source_lines = self.filtered(lambda l: not l.source_order_line_id) + warranty_lines = self.env["sale.order.line"].search([ + ("source_order_line_id", "in", source_lines.ids) + ]) + + configs = self.env["product.warranty.config"].search([ + ("product_id", "in", warranty_lines.mapped("product_id").ids) + ]).mapped(lambda c: (c.product_id.id, c)) + config_map = dict(configs) + + for wl in warranty_lines: + source = wl.source_order_line_id + config = config_map.get(wl.product_id.id) + if source in source_lines and config: + wl.price_unit = source.price_unit * (config.percentage / 100.0) + return res diff --git a/product_warranty/security/ir.model.access.csv b/product_warranty/security/ir.model.access.csv new file mode 100644 index 00000000000..664f0d219e6 --- /dev/null +++ b/product_warranty/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +product_warranty.access_product_warranty_config,access_product_warranty_config,product_warranty.model_product_warranty_config,base.group_user,1,1,1,1 +product_warranty.access_product_warranty_wizard,access_product_warranty_wizard,product_warranty.model_product_warranty_wizard,base.group_user,1,1,1,1 +product_warranty.access_product_warranty_wizard_lines,access_product_warranty_wizard_lines,product_warranty.model_product_warranty_wizard_lines,base.group_user,1,1,1,1 diff --git a/product_warranty/tests/__init__.py b/product_warranty/tests/__init__.py new file mode 100644 index 00000000000..499e999b434 --- /dev/null +++ b/product_warranty/tests/__init__.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_product_warranty_wizard diff --git a/product_warranty/tests/test_product_warranty_wizard.py b/product_warranty/tests/test_product_warranty_wizard.py new file mode 100644 index 00000000000..1e120e22817 --- /dev/null +++ b/product_warranty/tests/test_product_warranty_wizard.py @@ -0,0 +1,64 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase + + +class TestProductWarrantyWizard(TransactionCase): + + def setUp(self): + super().setUp() + self.partner = self.env['res.partner'].create({ + 'name': 'Test Customer' + }) + ProductTemplate = self.env['product.template'] + warranty_template = ProductTemplate.create({ + 'name': 'Warranty Product', + 'warranty': True, + }) + no_warranty_template = ProductTemplate.create({ + 'name': 'Non-Warranty Product', + 'warranty': False, + }) + self.warranty_product = warranty_template.product_variant_id + self.no_warranty_product = no_warranty_template.product_variant_id + + self.sale_order = self.env['sale.order'].create({ + 'partner_id': self.partner.id, + }) + self.sol_with_warranty = self.env['sale.order.line'].create({ + 'order_id': self.sale_order.id, + 'product_id': self.warranty_product.id, + 'product_uom_qty': 1, + 'price_unit': 100, + }) + self.sol_without_warranty = self.env['sale.order.line'].create({ + 'order_id': self.sale_order.id, + 'product_id': self.no_warranty_product.id, + 'product_uom_qty': 1, + 'price_unit': 100, + }) + + def test_default_get_populates_wizard_lines(self): + """Wizard should only include products with warranty=True""" + + wizard = self.env['product.warranty.wizard'].with_context( + default_sale_order_id=self.sale_order.id + ).new({}) + + wizard.default_get(['wizard_line_ids']) + self.assertEqual(len(wizard.wizard_line_ids), 1) + + wizard_line = wizard.wizard_line_ids[0] + self.assertEqual(wizard_line.product_id.id, self.warranty_product.id) + self.assertEqual(wizard_line.sale_order_line_id.id, self.sol_with_warranty.id) + + def test_action_add_warranty_raises_if_config_missing(self): + """Should raise UserError if any wizard line has no warranty_config_id""" + + wizard = self.env['product.warranty.wizard'].with_context( + default_sale_order_id=self.sale_order.id + ).create({}) + + with self.assertRaises(UserError): + wizard.action_add_warranty() diff --git a/product_warranty/views/product_warranty.xml b/product_warranty/views/product_warranty.xml new file mode 100644 index 00000000000..b5bd80dade0 --- /dev/null +++ b/product_warranty/views/product_warranty.xml @@ -0,0 +1,15 @@ + + + + product.template.inherit.view.form + product.template + + + + + + + + + + diff --git a/product_warranty/views/product_warranty_config_menus.xml b/product_warranty/views/product_warranty_config_menus.xml new file mode 100644 index 00000000000..0cdcd7ed90c --- /dev/null +++ b/product_warranty/views/product_warranty_config_menus.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/product_warranty/views/product_warranty_config_views.xml b/product_warranty/views/product_warranty_config_views.xml new file mode 100644 index 00000000000..69428cd66fc --- /dev/null +++ b/product_warranty/views/product_warranty_config_views.xml @@ -0,0 +1,25 @@ + + + + Product Warranty Configuration + product.warranty.config + list + +

+ Configure the product warranty here. +

+
+
+ + product.warranty.config.list + product.warranty.config + + + + + + + + + +
diff --git a/product_warranty/views/sale_order_views.xml b/product_warranty/views/sale_order_views.xml new file mode 100644 index 00000000000..67d6fffa884 --- /dev/null +++ b/product_warranty/views/sale_order_views.xml @@ -0,0 +1,19 @@ + + + + sale.order.warranty.inherit + sale.order + + +
+ +
+
+
+
diff --git a/product_warranty/wizard/__init__.py b/product_warranty/wizard/__init__.py new file mode 100644 index 00000000000..0c53a10a688 --- /dev/null +++ b/product_warranty/wizard/__init__.py @@ -0,0 +1,4 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import product_warranty_wizard +from . import product_warranty_wizard_lines diff --git a/product_warranty/wizard/product_warranty_wizard.py b/product_warranty/wizard/product_warranty_wizard.py new file mode 100644 index 00000000000..b4245b8d795 --- /dev/null +++ b/product_warranty/wizard/product_warranty_wizard.py @@ -0,0 +1,55 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, Command, api, fields, models +from odoo.exceptions import UserError + + +class ProductWarrantyWizard(models.TransientModel): + _name = "product.warranty.wizard" + _description = "Product Warranty Wizard" + + wizard_line_ids = fields.One2many( + comodel_name="product.warranty.wizard.lines", + inverse_name="wizard_id", string="Warranty Lines", + help="Lines for adding warranty products" + ) + sale_order_id = fields.Many2one( + comodel_name="sale.order", string="Sale Order", + help="Reference to the sale order for which warranty products are being added" + ) + + @api.model + def default_get(self, fields): + res = super().default_get(fields) + sale_order = self.env["sale.order"].browse( + self.env.context.get("default_sale_order_id") + ) + sale_order_line_id = sale_order.order_line.filtered( + lambda l: l.product_template_id.warranty + ) + res["wizard_line_ids"] = [ + Command.create({"product_id": line.product_id.id, "sale_order_line_id": line.id}) + for line in sale_order_line_id + ] + return res + + def action_add_warranty(self): + for line in self.wizard_line_ids: + if not line.warranty_config_id: + raise UserError(_("Warranty Configuration is required for all warranty lines.")) + + warranty_lines = [] + for line in self.wizard_line_ids: + base_price = line.sale_order_line_id.price_unit + warranty_price = base_price * (line.warranty_config_id.percentage / 100.0) + warranty_lines.append({ + "order_id": self.sale_order_id.id, + "product_id": line.warranty_config_id.product_id.id, + "name": "End Date:" + line.end_date, + "price_unit": warranty_price, + "product_uom_qty": 1.0, + "source_order_line_id": line.sale_order_line_id.id, + "sequence": line.sale_order_line_id.sequence, + }) + if warranty_lines: + self.env["sale.order.line"].create(warranty_lines) diff --git a/product_warranty/wizard/product_warranty_wizard.xml b/product_warranty/wizard/product_warranty_wizard.xml new file mode 100644 index 00000000000..f1c8f09fe6d --- /dev/null +++ b/product_warranty/wizard/product_warranty_wizard.xml @@ -0,0 +1,24 @@ + + + + product.warranty.wizard.form + product.warranty.wizard + +
+ + + + + + + + + +
+
+
+
diff --git a/product_warranty/wizard/product_warranty_wizard_lines.py b/product_warranty/wizard/product_warranty_wizard_lines.py new file mode 100644 index 00000000000..788f29bc29f --- /dev/null +++ b/product_warranty/wizard/product_warranty_wizard_lines.py @@ -0,0 +1,37 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class ProductWarrantyLinesWizard(models.TransientModel): + _name = "product.warranty.wizard.lines" + _description = "Product Warranty Wizard Lines" + + product_id = fields.Many2one( + comodel_name="product.product", string="Product", + ondelete="cascade", readonly=True + ) + end_date = fields.Text( + string="Warranty EndDate", store=True, + compute="_compute_end_date" + ) + warranty_config_id = fields.Many2one( + comodel_name="product.warranty.config", + string="Warranty Configuration", + help="Select the warranty configuration for this product" + ) + wizard_id = fields.Many2one( + comodel_name="product.warranty.wizard", + string="Wizard Reference", readonly=True, + help="Reference to the parent wizard" + ) + sale_order_line_id = fields.Many2one("sale.order.line", string="Original Order Line") + + @api.depends("warranty_config_id") + def _compute_end_date(self): + for line in self: + line.end_date = ( + fields.Date.add(fields.Date.today(), years=line.warranty_config_id.year) + if line.warranty_config_id + else "No warranty configuration selected" + )