From 936ecfe56c818d327f7b7ed5d519970bfe502fa7 Mon Sep 17 00:00:00 2001 From: Alexis de Lattre Date: Wed, 22 Feb 2023 23:53:29 +0100 Subject: [PATCH] [MIG] account_netting to v16 Add prepare method for the account.move created by the wizard Add multi-company checks Improve error messages Improve usability: use monetary instead of float, display partner Use odoo methods to compare floats Update tests --- account_netting/__manifest__.py | 2 +- account_netting/tests/test_account_netting.py | 88 +++++----- .../wizards/account_move_make_netting.py | 154 +++++++++++------- .../account_move_make_netting_view.xml | 7 +- .../odoo/addons/account_netting | 1 + setup/account_netting/setup.py | 6 + 6 files changed, 161 insertions(+), 97 deletions(-) create mode 120000 setup/account_netting/odoo/addons/account_netting create mode 100644 setup/account_netting/setup.py diff --git a/account_netting/__manifest__.py b/account_netting/__manifest__.py index 510ee012..5111cbd2 100644 --- a/account_netting/__manifest__.py +++ b/account_netting/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Account netting", - "version": "15.0.1.0.0", + "version": "16.0.1.0.0", "summary": "Compensate AR/AP accounts from the same partner", "category": "Accounting & Finance", "author": "Tecnativa, Odoo Community Association (OCA)", diff --git a/account_netting/tests/test_account_netting.py b/account_netting/tests/test_account_netting.py index dfdde521..19378a87 100644 --- a/account_netting/tests/test_account_netting.py +++ b/account_netting/tests/test_account_netting.py @@ -6,15 +6,16 @@ from datetime import datetime import odoo.tests.common as common -from odoo.exceptions import ValidationError +from odoo.exceptions import UserError from odoo.tests import Form, tagged -@tagged("post_install") +@tagged("post_install", "-at_install") class TestAccountNetting(common.TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) res_users_account_manager = cls.env.ref("account.group_account_manager") partner_manager = cls.env.ref("base.group_partner_manager") cls.env.user.write( @@ -23,19 +24,25 @@ class TestAccountNetting(common.TransactionCase): cls.company = cls.env.ref("base.main_company") # only adviser can create an account cls.aa_model = cls.env["account.account"] - cls.account_receivable = cls._get_account(cls, "receivable") - cls.account_payable = cls._get_account(cls, "payable") - cls.account_revenue = cls._get_account(cls, "revenue") - cls.account_expense = cls._get_account(cls, "expenses") + cls.account_receivable = cls._get_account(cls, "asset_receivable") + cls.account_payable = cls._get_account(cls, "liability_payable") + cls.account_revenue = cls._get_account(cls, "income") + cls.account_expense = cls._get_account(cls, "expense") cls.partner_model = cls.env["res.partner"] - cls.partner = cls._create_partner(cls, "Supplier/Customer") cls.partner1 = cls._create_partner(cls, "Supplier/Customer 1") + cls.partner2 = cls._create_partner(cls, "Supplier/Customer 2") cls.miscellaneous_journal = cls.env["account.journal"].search( - [("type", "=", "general")], limit=1 + [("type", "=", "general"), ("company_id", "=", cls.company.id)], limit=1 ) # We need a product with taxes at 0 so that the amounts are as expected. cls.account_tax = cls.env["account.tax"].create( - {"name": "0%", "amount_type": "fixed", "type_tax_use": "sale", "amount": 0} + { + "name": "0%", + "amount_type": "fixed", + "type_tax_use": "sale", + "amount": 0, + "company_id": cls.company.id, + } ) cls.product = cls.env["product.product"].create( { @@ -44,40 +51,39 @@ class TestAccountNetting(common.TransactionCase): "taxes_id": [(6, 0, [cls.account_tax.id])], } ) - out_invoice_partner = cls._create_move(cls, "out_invoice", cls.partner, 100) - out_invoice_partner.action_post() - cls.move_line_1 = out_invoice_partner.line_ids.filtered( + out_invoice_partner1 = cls._create_move(cls, "out_invoice", cls.partner1, 100) + out_invoice_partner1.action_post() + cls.move_line_1 = out_invoice_partner1.line_ids.filtered( lambda x: x.account_id == cls.account_receivable ) - in_invoice_partner = cls._create_move(cls, "in_invoice", cls.partner, 1200) - in_invoice_partner.action_post() - cls.move_line_2 = in_invoice_partner.line_ids.filtered( + in_invoice_partner1 = cls._create_move(cls, "in_invoice", cls.partner1, 1200) + in_invoice_partner1.action_post() + cls.move_line_2 = in_invoice_partner1.line_ids.filtered( lambda x: x.account_id == cls.account_payable ) - cls.move_line_3 = in_invoice_partner.line_ids.filtered( + cls.move_line_3 = in_invoice_partner1.line_ids.filtered( lambda x: x.account_id == cls.account_expense ) - in_invoice_partner1 = cls._create_move(cls, "in_invoice", cls.partner1, 200) - in_invoice_partner1.action_post() - cls.move_line_4 = in_invoice_partner1.line_ids.filtered( + in_invoice_partner2 = cls._create_move(cls, "in_invoice", cls.partner2, 200) + in_invoice_partner2.action_post() + cls.move_line_4 = in_invoice_partner2.line_ids.filtered( lambda x: x.account_id == cls.account_payable ) - in_refund_partner1 = cls._create_move(cls, "in_refund", cls.partner1, 200) - in_refund_partner1.action_post() - cls.move_line_5 = in_refund_partner1.line_ids.filtered( + in_refund_partner2 = cls._create_move(cls, "in_refund", cls.partner2, 200) + in_refund_partner2.action_post() + cls.move_line_5 = in_refund_partner2.line_ids.filtered( lambda x: x.account_id == cls.account_payable ) - in_refund_partner1 = cls._create_move(cls, "in_refund", cls.partner1, 200) - in_refund_partner1.action_post() - cls.move_line_6 = in_refund_partner1.line_ids.filtered( + in_refund_partner2 = cls._create_move(cls, "in_refund", cls.partner2, 200) + in_refund_partner2.action_post() + cls.move_line_6 = in_refund_partner2.line_ids.filtered( lambda x: x.account_id == cls.account_payable ) - def _get_account(self, user_type): - user_type_ref = "account.data_account_type_%s" % user_type + def _get_account(self, account_type): return self.aa_model.search( [ - ("user_type_id", "=", self.env.ref(user_type_ref).id), + ("account_type", "=", account_type), ("company_id", "=", self.company.id), ], limit=1, @@ -94,7 +100,9 @@ class TestAccountNetting(common.TransactionCase): def _create_move(self, move_type, partner, price): move_form = Form( - self.env["account.move"].with_context( + self.env["account.move"] + .with_company(self.company.id) + .with_context( default_move_type=move_type, ) ) @@ -106,22 +114,22 @@ class TestAccountNetting(common.TransactionCase): return move_form.save() def test_compensation(self): - # Test exception line 33 from account_move_make_netting + # Test raise if 1 account.move.line selected obj = self.env["account.move.make.netting"].with_context( active_ids=[self.move_line_1.id] ) - with self.assertRaises(ValidationError): + with self.assertRaises(UserError): wizard = obj.create( { "move_line_ids": [(6, 0, [self.move_line_1.id])], "journal_id": self.miscellaneous_journal.id, } ) - # Test exception line 39 from account_move_make_netting + # Test raise if not all accounts are payable/receivable obj = self.env["account.move.make.netting"].with_context( active_ids=[self.move_line_1.id, self.move_line_3.id] ) - with self.assertRaises(ValidationError): + with self.assertRaises(UserError): wizard = obj.create( { "move_line_ids": [ @@ -130,11 +138,11 @@ class TestAccountNetting(common.TransactionCase): "journal_id": self.miscellaneous_journal.id, } ) - # Test exception line 45 from account_move_make_netting + # Test raise if same account obj = self.env["account.move.make.netting"].with_context( active_ids=[self.move_line_4.id, self.move_line_5.id] ) - with self.assertRaises(ValidationError): + with self.assertRaises(UserError): wizard = obj.create( { "move_line_ids": [ @@ -143,7 +151,7 @@ class TestAccountNetting(common.TransactionCase): "journal_id": self.miscellaneous_journal.id, } ) - # Test exception line 42 from account_move_make_netting + # Test raise if reconciled lines moves = self.env["account.move.line"].browse( [self.move_line_4.id, self.move_line_5.id] ) @@ -151,7 +159,7 @@ class TestAccountNetting(common.TransactionCase): obj = self.env["account.move.make.netting"].with_context( active_ids=[self.move_line_4.id, self.move_line_5.id] ) - with self.assertRaises(ValidationError): + with self.assertRaises(UserError): wizard = obj.create( { "move_line_ids": [ @@ -160,11 +168,11 @@ class TestAccountNetting(common.TransactionCase): "journal_id": self.miscellaneous_journal.id, } ) - # Test exception line 52 from account_move_make_netting + # Test raise if different partners obj = self.env["account.move.make.netting"].with_context( active_ids=[self.move_line_1.id, self.move_line_6.id] ) - with self.assertRaises(ValidationError): + with self.assertRaises(UserError): wizard = obj.create( { "move_line_ids": [ @@ -182,6 +190,8 @@ class TestAccountNetting(common.TransactionCase): "journal_id": self.miscellaneous_journal.id, } ) + self.assertEqual(wizard.partner_id, self.partner1) + self.assertEqual(wizard.company_id, self.company) res = wizard.button_compensate() move = self.env["account.move"].browse(res["res_id"]) self.assertEqual( diff --git a/account_netting/wizards/account_move_make_netting.py b/account_netting/wizards/account_move_make_netting.py index 6554ca27..d986da1b 100644 --- a/account_netting/wizards/account_move_make_netting.py +++ b/account_netting/wizards/account_move_make_netting.py @@ -2,20 +2,29 @@ # Copyright 2017 Tecnativa - Vicent Cubells # License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html -from odoo import _, api, exceptions, fields, models +from odoo import _, api, fields, models +from odoo.exceptions import UserError class AccountMoveMakeNetting(models.TransientModel): _name = "account.move.make.netting" _description = "Wizard to generate account moves for netting" + _check_company_auto = True + company_id = fields.Many2one("res.company", required=True) journal_id = fields.Many2one( comodel_name="account.journal", required=True, - domain="[('type', '=', 'general')]", + domain="[('type', '=', 'general'), ('company_id', '=', company_id)]", + check_company=True, ) - move_line_ids = fields.Many2many(comodel_name="account.move.line") - balance = fields.Float(readonly=True) + move_line_ids = fields.Many2many( + comodel_name="account.move.line", + check_company=True, + ) + partner_id = fields.Many2one("res.partner", readonly=True) + company_currency_id = fields.Many2one(related="company_id.currency_id") + balance = fields.Monetary(readonly=True, currency_field="company_currency_id") balance_type = fields.Selection( selection=[("pay", "To pay"), ("receive", "To receive")], readonly=True, @@ -24,54 +33,79 @@ class AccountMoveMakeNetting(models.TransientModel): @api.model def default_get(self, fields_list): if len(self.env.context.get("active_ids", [])) < 2: - raise exceptions.ValidationError( - _("You should compensate at least 2 journal entries.") - ) + raise UserError(_("You should compensate at least 2 journal items.")) move_lines = self.env["account.move.line"].browse( self.env.context["active_ids"] ) - if any( - x not in ("payable", "receivable") - for x in move_lines.mapped("account_id.user_type_id.type") - ): - raise exceptions.ValidationError( - _("All entries must have a receivable or payable account") - ) - if any(move_lines.mapped("reconciled")): - raise exceptions.ValidationError(_("All entries mustn't been reconciled")) - if len(move_lines.mapped("account_id")) == 1: - raise exceptions.ValidationError( + partners = self.env["res.partner"] + for line in move_lines: + if line.parent_state != "posted": + raise UserError(_("Line '%s' is not posted.") % line.display_name) + if line.account_id.account_type not in ( + "liability_payable", + "asset_receivable", + ): + raise UserError( + _( + "Line '%(line)s' has account '%(account)s' which is not " + "a payable nor a receivable account." + ) + % { + "line": line.display_name, + "account": line.account_id.display_name, + } + ) + if line.reconciled: + raise UserError( + _("Line '%s' is already reconciled.") % line.display_name + ) + if not line.partner_id: + raise UserError( + _("Line '%s' doesn't have a partner.") % line.display_name + ) + partners |= line.partner_id + + if len(move_lines.account_id) == 1: + raise UserError( _( "The 'Compensate' function is intended to balance " - "operations on different accounts for the same partner.\n" + "operations on different accounts for the same partner. " "In this case all selected entries belong to the same " - "account.\n Please use the 'Reconcile' function." + "account '%s'. Use the 'Reconcile' function instead." ) + % move_lines.account_id.display_name ) - if len(move_lines.mapped("partner_id")) != 1: - raise exceptions.ValidationError( + if len(partners) != 1: + raise UserError( _( - "All entries should have a partner and the partner must " - "be the same for all." + "The selected journal items have different partners: %s. " + "The partner must be the same for all the selected journal items." ) + % ", ".join([p.display_name for p in partners]) ) res = super().default_get(fields_list) - res["move_line_ids"] = [(6, 0, move_lines.ids)] + company = self.env.company + ccur = company.currency_id debit_move_lines_debit = move_lines.filtered("debit") credit_move_lines_debit = move_lines.filtered("credit") - balance = abs(sum(debit_move_lines_debit.mapped("amount_residual"))) - abs( - sum(credit_move_lines_debit.mapped("amount_residual")) + balance = ccur.round( + abs(sum(debit_move_lines_debit.mapped("amount_residual"))) + - abs(sum(credit_move_lines_debit.mapped("amount_residual"))) + ) + res.update( + { + "balance": abs(balance), + "balance_type": "pay" + if ccur.compare_amounts(balance, 0) < 0 + else "receive", + "company_id": company.id, + "move_line_ids": move_lines.ids, + "partner_id": partners.id, + } ) - res["balance"] = abs(balance) - res["balance_type"] = "pay" if balance < 0 else "receive" return res - def button_compensate(self): - self.ensure_one() - # Create account move - move = self.env["account.move"].create( - {"ref": _("AR/AP netting"), "journal_id": self.journal_id.id} - ) + def _prepare_account_move(self): # Group amounts by account account_groups = self.move_line_ids.read_group( [("id", "in", self.move_line_ids.ids)], @@ -80,44 +114,50 @@ class AccountMoveMakeNetting(models.TransientModel): ) debtors = [] creditors = [] - total_debtors = 0 - total_creditors = 0 + total_debtors = 0.0 + total_creditors = 0.0 + ccur = self.company_id.currency_id for account_group in account_groups: balance = account_group["amount_residual"] group_vals = { "account_id": account_group["account_id"][0], "balance": abs(balance), } - if balance > 0: + if ccur.compare_amounts(balance, 0) > 0: debtors.append(group_vals) total_debtors += balance else: creditors.append(group_vals) total_creditors += abs(balance) - # Create move lines + # Compute move lines netting_amount = min(total_creditors, total_debtors) field_map = {1: "debit", 0: "credit"} move_lines = [] for i, group in enumerate([debtors, creditors]): available_amount = netting_amount for account_group in group: - if account_group["balance"] > available_amount: - amount = available_amount - else: - amount = account_group["balance"] move_line_vals = { - field_map[i]: amount, - "partner_id": self.move_line_ids[0].partner_id.id, - "name": move.ref, + field_map[i]: min(available_amount, account_group["balance"]), + "partner_id": self.partner_id.id, "account_id": account_group["account_id"], } move_lines.append((0, 0, move_line_vals)) available_amount -= account_group["balance"] - if available_amount <= 0: + if ccur.compare_amounts(available_amount, 0) <= 0: break - if move_lines: - move.write({"line_ids": move_lines}) - move.action_post() + vals = { + "ref": _("AR/AP netting"), + "journal_id": self.journal_id.id, + "company_id": self.company_id.id, + "line_ids": move_lines, + } + return vals + + def button_compensate(self): + self.ensure_one() + # Create account move + move = self.env["account.move"].create(self._prepare_account_move()) + move.action_post() # Make reconciliation for move_line in move.line_ids: to_reconcile = move_line + self.move_line_ids.filtered( @@ -125,11 +165,15 @@ class AccountMoveMakeNetting(models.TransientModel): ) to_reconcile.reconcile() # Open created move - action = self.env["ir.actions.act_window"]._for_xml_id( + action = self.env["ir.actions.actions"]._for_xml_id( "account.action_move_journal_line" ) - action["view_mode"] = "form" - del action["views"] - del action["view_id"] - action["res_id"] = move.id + action.update( + { + "view_mode": "form", + "views": False, + "view_id": False, + "res_id": move.id, + } + ) return action diff --git a/account_netting/wizards/account_move_make_netting_view.xml b/account_netting/wizards/account_move_make_netting_view.xml index fe2b21e6..4fc33d81 100644 --- a/account_netting/wizards/account_move_make_netting_view.xml +++ b/account_netting/wizards/account_move_make_netting_view.xml @@ -13,10 +13,13 @@ Compensate entries account.move.make.netting -
+

This operation will generate journal entries that are counterpart of the receivable/payable accounts selected, and reconcile each other, letting this balance in the partner.

+ >This operation will generate a journal entry whose lines are counterpart of the receivable/payable accounts selected, and reconcile each other, letting this balance in the partner.

+ + +