diff --git a/rental_deposit/__init__.py b/rental_deposit/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/rental_deposit/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/rental_deposit/__manifest__.py b/rental_deposit/__manifest__.py new file mode 100644 index 00000000000..119f6714ddb --- /dev/null +++ b/rental_deposit/__manifest__.py @@ -0,0 +1,18 @@ +{ + 'author': 'Aditi (adpaw)', + 'name': 'Deposit Rental App', + 'license': 'LGPL-3', + 'depends': ['sale_renting', 'website_sale'], + 'data': [ + 'views/res_config_settings_view.xml', + 'views/product_template_view.xml', + 'views/template_view.xml' + ], + 'assets': { + 'web.assets_frontend': [ + 'rental_deposit/static/src/deposit_amount.js', + ], + }, + 'installable': True, + 'auto_install': True, +} diff --git a/rental_deposit/models/__init__.py b/rental_deposit/models/__init__.py new file mode 100644 index 00000000000..90b06a9bf48 --- /dev/null +++ b/rental_deposit/models/__init__.py @@ -0,0 +1,4 @@ +from . import res_config_settings +from . import product_template +from . import sale_order_line +from . import res_company diff --git a/rental_deposit/models/product_template.py b/rental_deposit/models/product_template.py new file mode 100644 index 00000000000..76d32810754 --- /dev/null +++ b/rental_deposit/models/product_template.py @@ -0,0 +1,8 @@ +from odoo import fields, models + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + requires_deposit = fields.Boolean(string="Requires Deposit") + deposit_amount = fields.Float() diff --git a/rental_deposit/models/res_company.py b/rental_deposit/models/res_company.py new file mode 100644 index 00000000000..20d11bd60a1 --- /dev/null +++ b/rental_deposit/models/res_company.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + deposit_product = fields.Many2one("product.product") diff --git a/rental_deposit/models/res_config_settings.py b/rental_deposit/models/res_config_settings.py new file mode 100644 index 00000000000..35ef96e4b71 --- /dev/null +++ b/rental_deposit/models/res_config_settings.py @@ -0,0 +1,12 @@ +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + deposit_product = fields.Many2one( + "product.product", + related="company_id.deposit_product", + string="Deposit", + readonly=False + ) diff --git a/rental_deposit/models/sale_order_line.py b/rental_deposit/models/sale_order_line.py new file mode 100644 index 00000000000..2eb4904c8a8 --- /dev/null +++ b/rental_deposit/models/sale_order_line.py @@ -0,0 +1,94 @@ +from odoo import api, fields, models +from odoo.exceptions import UserError + + +class SaleOrderLine(models.Model): + _inherit = 'sale.order.line' + + is_deposit_line = fields.Boolean(default=False) + parent_rental_line_id = fields.Many2one( + 'sale.order.line', + string="Parent Rental Line", + ondelete="cascade", + index=True + ) + + @api.model_create_multi + def create(self, vals_list): + lines = super().create(vals_list) + rental_lines = lines.filtered( + lambda l: l.product_id.rent_ok + and l.product_id.requires_deposit + and l.product_id.deposit_amount > 0 + and not l.is_deposit_line + ) + if not rental_lines: + return lines + company_map = {} + for line in rental_lines: + company = line.company_id + if company not in company_map: + if not company.deposit_product: + raise UserError("Please set deposit product in settings.") + company_map[company] = company.deposit_product + deposit_vals = [] + for line in rental_lines: + deposit_product = company_map[line.company_id] + deposit_vals.append({ + 'order_id': line.order_id.id, + 'product_id': deposit_product.id, + 'product_uom_qty': line.product_uom_qty, + 'price_unit': line.product_id.deposit_amount, + 'name': f"Deposit fee for product: {line.product_id.name}", + 'is_deposit_line': True, + 'parent_rental_line_id': line.id, + }) + self.create(deposit_vals) + return lines + + # deposit_vals = [] + # for line in rental_lines: + # deposit_product = line.company_id.deposit_product + # if not deposit_product: + # raise UserError("Please set deposit product in settings.") + # deposit_vals.append({ + # 'order_id': line.order_id.id, + # 'product_id': deposit_product.id, + # 'product_uom_qty': line.product_uom_qty, + # 'price_unit': line.product_id.deposit_amount, + # 'name': f"Deposit fee for product: {line.product_id.name}", + # 'is_deposit_line': True, + # 'parent_rental_line_id': line.id, + # }) + # self.create(deposit_vals) + # return lines + + @api.ondelete(at_uninstall=False) + def _unlink_deposit_fee(self): + if not self.env.context.get("bypass_deposit_protection_for_delete"): + for record in self: + if record.is_deposit_line: + raise UserError("You can't delete a Deposit Product line directly.") + + def write(self, vals): + if not self.env.context.get("bypass_deposit_protection_for_write"): + for record in self: + if record.is_deposit_line: + raise UserError("You can't edit Deposit Product line directly.") + res = super().write(vals) + if 'product_uom_qty' not in vals: + return res + rental_lines = self.filtered( + lambda l: l.product_id.rent_ok + and l.product_id.requires_deposit + and l.product_id.deposit_amount > 0 + and not l.is_deposit_line + ) + for line in rental_lines: + deposit_line = self.search([('parent_rental_line_id', '=', line.id)], limit=1) + if deposit_line: + deposit_line.with_context(bypass_deposit_protection_for_write=True).write({ + 'product_uom_qty': line.product_uom_qty, + 'price_unit': line.product_id.deposit_amount, + }) + return res diff --git a/rental_deposit/static/src/deposit_amount.js b/rental_deposit/static/src/deposit_amount.js new file mode 100644 index 00000000000..af0a325e752 --- /dev/null +++ b/rental_deposit/static/src/deposit_amount.js @@ -0,0 +1,27 @@ +import { Component, onMounted } from "@odoo/owl"; +import { registry } from "@web/core/registry"; + +export class DepositAmount extends Component { + setup() { + onMounted(() => { + const depositEl = document.querySelector('#deposit_amount_val'); + const qtyInput = document.querySelector('input[name="add_qty"]'); + + if (!depositEl || !qtyInput) return; + + const baseAmount = parseFloat(depositEl.dataset.baseAmount) || 0; + + const calculateTotal = () => { + const quantity = parseFloat(qtyInput.value) || 1; + depositEl.textContent = (baseAmount * quantity).toFixed(2); + }; + + qtyInput.addEventListener('input', calculateTotal); + qtyInput.addEventListener('change', calculateTotal); + + calculateTotal(); + }); + } +} + +registry.category("public_components").add("deposit_amount", DepositAmount); diff --git a/rental_deposit/tests/__init__.py b/rental_deposit/tests/__init__.py new file mode 100644 index 00000000000..0c75457509d --- /dev/null +++ b/rental_deposit/tests/__init__.py @@ -0,0 +1,2 @@ +from . import common +from . import test_sale_order_line diff --git a/rental_deposit/tests/common.py b/rental_deposit/tests/common.py new file mode 100644 index 00000000000..e5c8684b16b --- /dev/null +++ b/rental_deposit/tests/common.py @@ -0,0 +1,57 @@ +from odoo.tests.common import TransactionCase + + +class RentalDepositCommon(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.deposit_product = cls.env['product.product'].create({ + 'name': 'Deposit Fee', + 'type': 'service', + }) + cls.env.company.deposit_product = cls.deposit_product + + cls.rental_product_a = cls.env['product.product'].create({ + 'name': 'Test Projector', + 'rent_ok': True, + 'requires_deposit': True, + 'deposit_amount': 50.0, + }) + + cls.rental_product_b = cls.env['product.product'].create({ + 'name': 'Test Bike', + 'rent_ok': True, + 'requires_deposit': True, + 'deposit_amount': 30.0, + }) + + cls.rental_product_c = cls.env['product.product'].create({ + 'name': 'Test Camera', + 'rent_ok': True, + 'requires_deposit': True, + 'deposit_amount': 20.0, + }) + + cls.rental_no_deposit = cls.env['product.product'].create({ + 'name': 'Test Chair', + 'rent_ok': True, + 'requires_deposit': False, + }) + + cls.partner = cls.env['res.partner'].create({'name': 'Test Customer'}) + + def _make_order(self): + """Creates a fresh sale order each time.""" + return self.env['sale.order'].create({ + 'partner_id': self.partner.id, + }) + + def _add_line(self, order, product, qty=1, price=10.0): + """Helper — adds a single order line and returns it.""" + return self.env['sale.order.line'].create({ + 'order_id': order.id, + 'product_id': product.id, + 'product_uom_qty': qty, + 'price_unit': price, + }) diff --git a/rental_deposit/tests/test_sale_order_line.py b/rental_deposit/tests/test_sale_order_line.py new file mode 100644 index 00000000000..6fa42346bce --- /dev/null +++ b/rental_deposit/tests/test_sale_order_line.py @@ -0,0 +1,195 @@ +from odoo.exceptions import UserError + +from .common import RentalDepositCommon + + +class TestSaleOrderLine(RentalDepositCommon): + + # single product + + def test_deposit_line_created_on_rental_product_add(self): + # adding single rental product to create one deposit line + order = self._make_order() + self._add_line(order, self.rental_product_a) + + deposit_lines = order.order_line.filtered(lambda l: l.is_deposit_line) + self.assertEqual(len(deposit_lines), 1) + self.assertEqual(deposit_lines.price_unit, 50.0) + + def test_deposit_line_linked_to_parent_rental_line(self): + order = self._make_order() + rental_line = self._add_line(order, self.rental_product_a) + + deposit_line = order.order_line.filtered(lambda l: l.is_deposit_line) + self.assertEqual(deposit_line.parent_rental_line_id, rental_line) + + def test_no_deposit_line_for_non_deposit_product(self): + order = self._make_order() + self._add_line(order, self.rental_no_deposit) + + deposit_lines = order.order_line.filtered(lambda l: l.is_deposit_line) + self.assertEqual(len(deposit_lines), 0) + + def test_deposit_line_qty_syncs_on_write(self): + order = self._make_order() + rental_line = self._add_line(order, self.rental_product_a, qty=1) + + rental_line.write({'product_uom_qty': 3}) + + deposit_line = order.order_line.filtered(lambda l: l.is_deposit_line) + self.assertEqual(deposit_line.product_uom_qty, 3) + + def test_deposit_line_deleted_with_rental_line(self): + # deposit line should be removed via cascade + order = self._make_order() + rental_line = self._add_line(order, self.rental_product_a) + + rental_line.unlink() + + deposit_lines = order.order_line.filtered(lambda l: l.is_deposit_line) + self.assertEqual(len(deposit_lines), 0) + + def test_direct_edit_of_deposit_line_raises_error(self): + order = self._make_order() + self._add_line(order, self.rental_product_a) + + deposit_line = order.order_line.filtered(lambda l: l.is_deposit_line) + with self.assertRaises(UserError): + deposit_line.write({'price_unit': 999}) + + def test_direct_delete_of_deposit_line_raises_error(self): + order = self._make_order() + self._add_line(order, self.rental_product_a) + + deposit_line = order.order_line.filtered(lambda l: l.is_deposit_line) + with self.assertRaises(UserError): + deposit_line.unlink() + + def test_error_when_deposit_product_not_configured(self): + self.env.company.deposit_product = False + order = self._make_order() + + with self.assertRaises(UserError): + self._add_line(order, self.rental_product_a) + + # reset so other tests aren't affected + self.env.company.deposit_product = self.deposit_product + + # multiple products + + def test_multiple_deposit_lines_for_multiple_products(self): + order = self._make_order() + self._add_line(order, self.rental_product_a) + self._add_line(order, self.rental_product_b) + self._add_line(order, self.rental_product_c) + + deposit_lines = order.order_line.filtered(lambda l: l.is_deposit_line) + self.assertEqual(len(deposit_lines), 3) + + def test_each_deposit_line_linked_to_correct_parent(self): + order = self._make_order() + line_a = self._add_line(order, self.rental_product_a) + line_b = self._add_line(order, self.rental_product_b) + + deposit_a = order.order_line.filtered( + lambda l: l.is_deposit_line and l.parent_rental_line_id == line_a + ) + deposit_b = order.order_line.filtered( + lambda l: l.is_deposit_line and l.parent_rental_line_id == line_b + ) + self.assertEqual(len(deposit_a), 1) + self.assertEqual(len(deposit_b), 1) + + def test_deposit_amounts_correct_for_multiple_products(self): + order = self._make_order() + self._add_line(order, self.rental_product_a) + self._add_line(order, self.rental_product_b) + self._add_line(order, self.rental_product_c) + + deposit_lines = order.order_line.filtered(lambda l: l.is_deposit_line) + self.assertEqual(sorted(deposit_lines.mapped('price_unit')), [20.0, 30.0, 50.0]) + + def test_mix_of_deposit_and_non_deposit_products(self): + order = self._make_order() + self._add_line(order, self.rental_product_a) + self._add_line(order, self.rental_no_deposit) + self._add_line(order, self.rental_product_b) + + deposit_lines = order.order_line.filtered(lambda l: l.is_deposit_line) + self.assertEqual(len(deposit_lines), 2) + + def test_no_duplicate_deposit_lines_on_resave(self): + # write() is called again when the order is saved — make sure + # it doesn't create a second deposit line for the same rental line + order = self._make_order() + self._add_line(order, self.rental_product_a) + + order.write({'note': 'updated'}) + + deposit_lines = order.order_line.filtered(lambda l: l.is_deposit_line) + self.assertEqual(len(deposit_lines), 1) + + def test_qty_sync_per_product_independently(self): + order = self._make_order() + line_a = self._add_line(order, self.rental_product_a, qty=1) + line_b = self._add_line(order, self.rental_product_b, qty=1) + + line_a.write({'product_uom_qty': 4}) + line_b.write({'product_uom_qty': 2}) + + deposit_a = order.order_line.filtered( + lambda l: l.is_deposit_line and l.parent_rental_line_id == line_a + ) + deposit_b = order.order_line.filtered( + lambda l: l.is_deposit_line and l.parent_rental_line_id == line_b + ) + self.assertEqual(deposit_a.product_uom_qty, 4) + self.assertEqual(deposit_b.product_uom_qty, 2) + + def test_qty_change_on_one_does_not_affect_other(self): + order = self._make_order() + line_a = self._add_line(order, self.rental_product_a, qty=1) + line_b = self._add_line(order, self.rental_product_b, qty=1) + + line_a.write({'product_uom_qty': 5}) + + deposit_b = order.order_line.filtered( + lambda l: l.is_deposit_line and l.parent_rental_line_id == line_b + ) + self.assertEqual(deposit_b.product_uom_qty, 1) + + def test_deleting_one_line_removes_only_its_deposit(self): + order = self._make_order() + line_a = self._add_line(order, self.rental_product_a) + line_b = self._add_line(order, self.rental_product_b) + + line_a.unlink() + + deposit_lines = order.order_line.filtered(lambda l: l.is_deposit_line) + self.assertEqual(len(deposit_lines), 1) + self.assertEqual(deposit_lines.parent_rental_line_id, line_b) + + def test_deleting_all_lines_removes_all_deposits(self): + order = self._make_order() + line_a = self._add_line(order, self.rental_product_a) + line_b = self._add_line(order, self.rental_product_b) + line_c = self._add_line(order, self.rental_product_c) + + (line_a | line_b | line_c).unlink() + + deposit_lines = order.order_line.filtered(lambda l: l.is_deposit_line) + self.assertEqual(len(deposit_lines), 0) + + def test_deleting_rental_line_keeps_non_deposit_line(self): + # make sure cascade only removes the deposit line tied to the deleted + # rental line, not unrelated lines on the same order + order = self._make_order() + line_a = self._add_line(order, self.rental_product_a) + self._add_line(order, self.rental_no_deposit) + + line_a.unlink() + + remaining = order.order_line.filtered( + lambda l: l.product_id == self.rental_no_deposit + ) + self.assertEqual(len(remaining), 1) diff --git a/rental_deposit/views/product_template_view.xml b/rental_deposit/views/product_template_view.xml new file mode 100644 index 00000000000..2b699cf135c --- /dev/null +++ b/rental_deposit/views/product_template_view.xml @@ -0,0 +1,13 @@ + + + product.template.view.form.inherit.rental.deposit + product.template + + + + + + + + + diff --git a/rental_deposit/views/res_config_settings_view.xml b/rental_deposit/views/res_config_settings_view.xml new file mode 100644 index 00000000000..0688dc17ab3 --- /dev/null +++ b/rental_deposit/views/res_config_settings_view.xml @@ -0,0 +1,13 @@ + + + res.config.settings.view.form.inherit.deposit + res.config.settings + + + + + + + + + diff --git a/rental_deposit/views/template_view.xml b/rental_deposit/views/template_view.xml new file mode 100644 index 00000000000..cbf184db8ea --- /dev/null +++ b/rental_deposit/views/template_view.xml @@ -0,0 +1,18 @@ + + + + + + + Deposit Required: + + + + + + + + +