diff --git a/purchase_deposit_preserve_amount/README.rst b/purchase_deposit_preserve_amount/README.rst new file mode 100644 index 00000000..53606625 --- /dev/null +++ b/purchase_deposit_preserve_amount/README.rst @@ -0,0 +1,58 @@ +================================ +Purchase Deposit Preserve Amount +================================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:dec5ee036437249ea0cd05823cc27d012220374d3b2d2473cd92f6cfee5c5b7e + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-qrtl%2Faxls--custom-lightgray.png?logo=github + :target: https://github.com/qrtl/axls-custom/tree/16.0/purchase_deposit_preserve_amount + :alt: qrtl/axls-custom + +|badge1| |badge2| |badge3| + +This module preserves the original deposit invoice amount on deposit +lines when creating vendor bills from purchase orders in a +multi-currency environment, ensuring that stock valuation layer +differences are calculated based on the preserved deposit amount. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Quartile + +Maintainers +----------- + +This module is part of the `qrtl/axls-custom `_ project on GitHub. + +You are welcome to contribute. diff --git a/purchase_deposit_preserve_amount/__init__.py b/purchase_deposit_preserve_amount/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/purchase_deposit_preserve_amount/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/purchase_deposit_preserve_amount/__manifest__.py b/purchase_deposit_preserve_amount/__manifest__.py new file mode 100644 index 00000000..f4a3997b --- /dev/null +++ b/purchase_deposit_preserve_amount/__manifest__.py @@ -0,0 +1,13 @@ +# Copyright 2025 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +{ + "name": "Purchase Deposit Preserve Amount", + "version": "16.0.1.0.0", + "author": "Quartile", + "website": "https://www.quartile.co", + "category": "Purchase Management", + "license": "AGPL-3", + "depends": ["purchase_deposit"], + "installable": True, +} diff --git a/purchase_deposit_preserve_amount/models/__init__.py b/purchase_deposit_preserve_amount/models/__init__.py new file mode 100644 index 00000000..702c78d2 --- /dev/null +++ b/purchase_deposit_preserve_amount/models/__init__.py @@ -0,0 +1,3 @@ +from . import account_move_line +from . import account_move +from . import uom_uom diff --git a/purchase_deposit_preserve_amount/models/account_move.py b/purchase_deposit_preserve_amount/models/account_move.py new file mode 100644 index 00000000..ba865a80 --- /dev/null +++ b/purchase_deposit_preserve_amount/models/account_move.py @@ -0,0 +1,92 @@ +# Copyright 2025 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class AccountMove(models.Model): + _inherit = "account.move" + + is_deposit = fields.Boolean(compute="_compute_is_deposit") + + def _compute_is_deposit(self): + for rec in self: + rec.is_deposit = any( + rec.invoice_line_ids.filtered( + lambda line: line.purchase_line_id.is_deposit and line.quantity > 0 + ) + ) + + def _adjust_journal_item_balances_for_deposit(self): + """Reconcile deposit and stock received balances when the move currency + differs from the company currency by resetting deposit lines to the + original deposit bill amount and redistributing the difference over + non-deposit product lines. + """ + for rec in self: + deposit_lines = rec.line_ids.filtered( + lambda line: line.display_type == "product" + and line.purchase_line_id.is_deposit + ) + if not deposit_lines: + continue + amount_diff = 0.0 + for line in deposit_lines: + balance = sum( + line.purchase_line_id.invoice_lines.filtered( + lambda l: l.move_id.state == "posted" and l.move_id.is_deposit + ).mapped("balance") + ) + amount_diff += balance + line.balance + line.with_context(skip_deposit_adjustment=True).balance = -1 * balance + if not amount_diff: + continue + product_lines = rec.line_ids.filtered( + lambda line: line.display_type == "product" + and line.purchase_line_id + and not line.purchase_line_id.is_deposit + ) + if not product_lines: + continue + line_count = len(product_lines) + total_balance = sum(product_lines.mapped("balance")) + remaining = amount_diff + for idx, line in enumerate(product_lines): + if idx < line_count - 1: + if total_balance: + raw_share = amount_diff * (line.balance / total_balance) + else: + raw_share = amount_diff / line_count + share = rec.currency_id.round(raw_share) + remaining -= share + else: + # last line gets whatever remains, to keep sums exact + share = rec.currency_id.round(remaining) + line.with_context(skip_deposit_adjustment=True).balance = ( + line.balance + share + ) + + # Expect to extend as necessary for other move types + def _moves_needing_deposit_adjustment(self): + return self.filtered( + lambda m: ( + m.move_type == "in_invoice" + and not m.is_deposit + and m.line_ids.filtered( + lambda l: l.purchase_line_id and l.purchase_line_id.is_deposit + ) + ) + ) + + @api.model_create_multi + def create(self, vals_list): + moves = super().create(vals_list) + moves._moves_needing_deposit_adjustment()._adjust_journal_item_balances_for_deposit() + return moves + + def write(self, vals): + res = super().write(vals) + if self.env.context.get("skip_deposit_adjustment"): + return res + self._moves_needing_deposit_adjustment()._adjust_journal_item_balances_for_deposit() + return res diff --git a/purchase_deposit_preserve_amount/models/account_move_line.py b/purchase_deposit_preserve_amount/models/account_move_line.py new file mode 100644 index 00000000..d2c75266 --- /dev/null +++ b/purchase_deposit_preserve_amount/models/account_move_line.py @@ -0,0 +1,17 @@ +# Copyright 2025 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import models + + +class AccountMoveLine(models.Model): + _inherit = "account.move.line" + + def _generate_price_difference_vals(self, layers): + self.ensure_one() + if not self.move_id.line_ids.filtered( + lambda line: line.purchase_line_id.is_deposit + ): + return super()._generate_price_difference_vals(layers) + self = self.with_context(need_deposit_adj_aml=self) + return super()._generate_price_difference_vals(layers) diff --git a/purchase_deposit_preserve_amount/models/uom_uom.py b/purchase_deposit_preserve_amount/models/uom_uom.py new file mode 100644 index 00000000..6c95553e --- /dev/null +++ b/purchase_deposit_preserve_amount/models/uom_uom.py @@ -0,0 +1,19 @@ +# Copyright 2025 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import models +from odoo.tools import float_round + + +class UOM(models.Model): + _inherit = "uom.uom" + + def _compute_price(self, price, to_unit): + self.ensure_one() + aml = self.env.context.get("need_deposit_adj_aml") + if aml and aml.quantity: + price = float_round( + aml.balance / aml.quantity, + precision_rounding=aml.company_currency_id.rounding, + ) + return super()._compute_price(price, to_unit) diff --git a/purchase_deposit_preserve_amount/readme/DESCRIPTION.md b/purchase_deposit_preserve_amount/readme/DESCRIPTION.md new file mode 100644 index 00000000..8923c940 --- /dev/null +++ b/purchase_deposit_preserve_amount/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +This module preserves the original deposit invoice amount on deposit lines when creating +vendor bills from purchase orders in a multi-currency environment, ensuring that stock +valuation layer differences are calculated based on the preserved deposit amount. diff --git a/purchase_deposit_preserve_amount/static/description/index.html b/purchase_deposit_preserve_amount/static/description/index.html new file mode 100644 index 00000000..fec7637a --- /dev/null +++ b/purchase_deposit_preserve_amount/static/description/index.html @@ -0,0 +1,412 @@ + + + + + +Purchase Deposit Preserve Amount + + + +
+

Purchase Deposit Preserve Amount

+ + +

Beta License: AGPL-3 qrtl/axls-custom

+

This module preserves the original deposit invoice amount on deposit +lines when creating vendor bills from purchase orders in a +multi-currency environment, ensuring that stock valuation layer +differences are calculated based on the preserved deposit amount.

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Quartile
  • +
+
+
+

Maintainers

+

This module is part of the qrtl/axls-custom project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/purchase_deposit_preserve_amount/tests/__init__.py b/purchase_deposit_preserve_amount/tests/__init__.py new file mode 100644 index 00000000..bf0480d1 --- /dev/null +++ b/purchase_deposit_preserve_amount/tests/__init__.py @@ -0,0 +1 @@ +from . import test_purchase_deposit_preserve_amount diff --git a/purchase_deposit_preserve_amount/tests/test_purchase_deposit_preserve_amount.py b/purchase_deposit_preserve_amount/tests/test_purchase_deposit_preserve_amount.py new file mode 100644 index 00000000..b2bcb3ee --- /dev/null +++ b/purchase_deposit_preserve_amount/tests/test_purchase_deposit_preserve_amount.py @@ -0,0 +1,186 @@ +# Copyright 2025 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields +from odoo.tests.common import Form, TransactionCase + + +class TestPurchaseDepositPreserveAmount(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env["res.company"].create( + { + "name": "test company", + "currency_id": cls.env.ref("base.JPY").id, + "country_id": cls.env.ref("base.jp").id, + } + ) + cls.env.user.company_id = cls.company + cls.currency_usd = cls.env.ref("base.USD") + cls.currency_usd.active = True + Rate = cls.env["res.currency.rate"] + Rate.create( + { + "name": "2025-10-01", + "currency_id": cls.currency_usd.id, + "company_id": cls.company.id, + "rate": 1 / 150.0, + } + ) + Rate.create( + { + "name": "2025-11-01", + "currency_id": cls.currency_usd.id, + "company_id": cls.company.id, + "rate": 1 / 160.0, + } + ) + Account = cls.env["account.account"] + account_payable = Account.create( + { + "code": "TEST1", + "name": "Payable", + "reconcile": True, + "account_type": "liability_payable", + "company_id": cls.company.id, + } + ) + account_expense = Account.create( + { + "code": "TEST2", + "name": "Expense", + "account_type": "expense", + "company_id": cls.company.id, + } + ) + stock_valuation = Account.create( + { + "code": "TEST3", + "name": "Stock Valuation", + "account_type": "asset_current", + "company_id": cls.company.id, + } + ) + stock_input = Account.create( + { + "code": "TEST4", + "name": "Stock Input", + "account_type": "asset_current", + "company_id": cls.company.id, + } + ) + stock_output = Account.create( + { + "code": "TEST5", + "name": "Stock Output", + "account_type": "asset_current", + "company_id": cls.company.id, + } + ) + cls.vendor = cls.env["res.partner"].create( + { + "name": "test partner", + "property_account_payable_id": account_payable.id, + "company_id": cls.company.id, + } + ) + stock_journal = cls.env["account.journal"].create( + { + "code": "Valuation", + "name": "Valuation Journal", + "type": "general", + "company_id": cls.company.id, + } + ) + cls.category = cls.env["product.category"].create( + { + "name": "Deposit Test Category", + "property_valuation": "real_time", + "property_cost_method": "fifo", + "property_account_expense_categ_id": account_expense.id, + "property_stock_valuation_account_id": stock_valuation.id, + "property_stock_account_input_categ_id": stock_input.id, + "property_stock_account_output_categ_id": stock_output.id, + "property_stock_journal": stock_journal.id, + } + ) + cls.product = cls.env["product.product"].create( + { + "name": "Deposit Test Product", + "type": "product", + "categ_id": cls.category.id, + "company_id": cls.company.id, + } + ) + cls.account_deposit = Account.create( + { + "name": "Purchase Deposit", + "code": "TEST6", + "account_type": "asset_current", + "company_id": cls.company.id, + } + ) + cls.journal = cls.env["account.journal"].create( + { + "code": "TP", + "name": "Test Purchase", + "type": "purchase", + "company_id": cls.company.id, + } + ) + + def _create_purchase_order(self): + with Form(self.env["purchase.order"]) as po_form: + po_form.partner_id = self.vendor + po_form.date_order = fields.Date.from_string("2025-10-01") + po_form.company_id = self.company + po_form.currency_id = self.currency_usd + with po_form.order_line.new() as line: + line.product_id = self.product + line.product_qty = 1.0 + line.price_unit = 100.0 + po = po_form.save() + po.button_confirm() + return po + + def create_advance_payment(self, po): + wizard_env = self.env["purchase.advance.payment.inv"].with_context( + active_id=po.id, + active_ids=po.ids, + active_model="purchase.order", + create_bills=True, + ) + with Form(wizard_env) as advance_form: + advance_form.advance_payment_method = "percentage" + advance_form.amount = 50 + advance_form.deposit_account_id = self.account_deposit + wizard = advance_form.save() + wizard.create_invoices() + + def test_preserve_deposit_amount_on_vendor_bill(self): + po = self._create_purchase_order() + self.create_advance_payment(po) + po.invoice_ids.invoice_date = fields.Date.from_string("2025-10-01") + deposit_line = po.invoice_ids.line_ids.filtered( + lambda l: l.move_id.is_deposit and l.purchase_line_id.is_deposit + ) + self.assertTrue(deposit_line, "Deposit bill should have a deposit line.") + po.invoice_ids.action_post() + original_deposit_balance = deposit_line.balance + po.picking_ids.move_ids.write({"quantity_done": 1}) + po.picking_ids.button_validate() + res = po.with_context(create_bill=True).action_create_invoice() + bill = self.env["account.move"].browse(res["res_id"]) + bill.invoice_date = fields.Date.today() + bill.action_post() + bill_deposit_line = bill.line_ids.filtered( + lambda l: l.purchase_line_id.is_deposit + ) + self.assertTrue( + bill_deposit_line, + "Vendor bill created from PO should contain a deposit line.", + ) + self.assertEqual(abs(bill_deposit_line.balance), original_deposit_balance) + svls = bill.line_ids.mapped("stock_valuation_layer_ids") + self.assertEqual(svls.value, -500.0) diff --git a/setup/purchase_deposit_preserve_amount/odoo/addons/purchase_deposit_preserve_amount b/setup/purchase_deposit_preserve_amount/odoo/addons/purchase_deposit_preserve_amount new file mode 120000 index 00000000..ecb16f2c --- /dev/null +++ b/setup/purchase_deposit_preserve_amount/odoo/addons/purchase_deposit_preserve_amount @@ -0,0 +1 @@ +../../../../purchase_deposit_preserve_amount \ No newline at end of file diff --git a/setup/purchase_deposit_preserve_amount/setup.py b/setup/purchase_deposit_preserve_amount/setup.py new file mode 100644 index 00000000..28c57bb6 --- /dev/null +++ b/setup/purchase_deposit_preserve_amount/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)