2
0

[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
This commit is contained in:
Alexis de Lattre 2023-02-22 23:53:29 +01:00
parent 4643b05b26
commit 936ecfe56c
6 changed files with 161 additions and 97 deletions

View File

@ -4,7 +4,7 @@
{ {
"name": "Account netting", "name": "Account netting",
"version": "15.0.1.0.0", "version": "16.0.1.0.0",
"summary": "Compensate AR/AP accounts from the same partner", "summary": "Compensate AR/AP accounts from the same partner",
"category": "Accounting & Finance", "category": "Accounting & Finance",
"author": "Tecnativa, Odoo Community Association (OCA)", "author": "Tecnativa, Odoo Community Association (OCA)",

View File

@ -6,15 +6,16 @@
from datetime import datetime from datetime import datetime
import odoo.tests.common as common import odoo.tests.common as common
from odoo.exceptions import ValidationError from odoo.exceptions import UserError
from odoo.tests import Form, tagged from odoo.tests import Form, tagged
@tagged("post_install") @tagged("post_install", "-at_install")
class TestAccountNetting(common.TransactionCase): class TestAccountNetting(common.TransactionCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
super().setUpClass() 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") res_users_account_manager = cls.env.ref("account.group_account_manager")
partner_manager = cls.env.ref("base.group_partner_manager") partner_manager = cls.env.ref("base.group_partner_manager")
cls.env.user.write( cls.env.user.write(
@ -23,19 +24,25 @@ class TestAccountNetting(common.TransactionCase):
cls.company = cls.env.ref("base.main_company") cls.company = cls.env.ref("base.main_company")
# only adviser can create an account # only adviser can create an account
cls.aa_model = cls.env["account.account"] cls.aa_model = cls.env["account.account"]
cls.account_receivable = cls._get_account(cls, "receivable") cls.account_receivable = cls._get_account(cls, "asset_receivable")
cls.account_payable = cls._get_account(cls, "payable") cls.account_payable = cls._get_account(cls, "liability_payable")
cls.account_revenue = cls._get_account(cls, "revenue") cls.account_revenue = cls._get_account(cls, "income")
cls.account_expense = cls._get_account(cls, "expenses") cls.account_expense = cls._get_account(cls, "expense")
cls.partner_model = cls.env["res.partner"] 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.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( 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. # We need a product with taxes at 0 so that the amounts are as expected.
cls.account_tax = cls.env["account.tax"].create( 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( cls.product = cls.env["product.product"].create(
{ {
@ -44,40 +51,39 @@ class TestAccountNetting(common.TransactionCase):
"taxes_id": [(6, 0, [cls.account_tax.id])], "taxes_id": [(6, 0, [cls.account_tax.id])],
} }
) )
out_invoice_partner = cls._create_move(cls, "out_invoice", cls.partner, 100) out_invoice_partner1 = cls._create_move(cls, "out_invoice", cls.partner1, 100)
out_invoice_partner.action_post() out_invoice_partner1.action_post()
cls.move_line_1 = out_invoice_partner.line_ids.filtered( cls.move_line_1 = out_invoice_partner1.line_ids.filtered(
lambda x: x.account_id == cls.account_receivable lambda x: x.account_id == cls.account_receivable
) )
in_invoice_partner = cls._create_move(cls, "in_invoice", cls.partner, 1200) in_invoice_partner1 = cls._create_move(cls, "in_invoice", cls.partner1, 1200)
in_invoice_partner.action_post() in_invoice_partner1.action_post()
cls.move_line_2 = in_invoice_partner.line_ids.filtered( cls.move_line_2 = in_invoice_partner1.line_ids.filtered(
lambda x: x.account_id == cls.account_payable 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 lambda x: x.account_id == cls.account_expense
) )
in_invoice_partner1 = cls._create_move(cls, "in_invoice", cls.partner1, 200) in_invoice_partner2 = cls._create_move(cls, "in_invoice", cls.partner2, 200)
in_invoice_partner1.action_post() in_invoice_partner2.action_post()
cls.move_line_4 = in_invoice_partner1.line_ids.filtered( cls.move_line_4 = in_invoice_partner2.line_ids.filtered(
lambda x: x.account_id == cls.account_payable lambda x: x.account_id == cls.account_payable
) )
in_refund_partner1 = cls._create_move(cls, "in_refund", cls.partner1, 200) in_refund_partner2 = cls._create_move(cls, "in_refund", cls.partner2, 200)
in_refund_partner1.action_post() in_refund_partner2.action_post()
cls.move_line_5 = in_refund_partner1.line_ids.filtered( cls.move_line_5 = in_refund_partner2.line_ids.filtered(
lambda x: x.account_id == cls.account_payable lambda x: x.account_id == cls.account_payable
) )
in_refund_partner1 = cls._create_move(cls, "in_refund", cls.partner1, 200) in_refund_partner2 = cls._create_move(cls, "in_refund", cls.partner2, 200)
in_refund_partner1.action_post() in_refund_partner2.action_post()
cls.move_line_6 = in_refund_partner1.line_ids.filtered( cls.move_line_6 = in_refund_partner2.line_ids.filtered(
lambda x: x.account_id == cls.account_payable lambda x: x.account_id == cls.account_payable
) )
def _get_account(self, user_type): def _get_account(self, account_type):
user_type_ref = "account.data_account_type_%s" % user_type
return self.aa_model.search( return self.aa_model.search(
[ [
("user_type_id", "=", self.env.ref(user_type_ref).id), ("account_type", "=", account_type),
("company_id", "=", self.company.id), ("company_id", "=", self.company.id),
], ],
limit=1, limit=1,
@ -94,7 +100,9 @@ class TestAccountNetting(common.TransactionCase):
def _create_move(self, move_type, partner, price): def _create_move(self, move_type, partner, price):
move_form = Form( 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, default_move_type=move_type,
) )
) )
@ -106,22 +114,22 @@ class TestAccountNetting(common.TransactionCase):
return move_form.save() return move_form.save()
def test_compensation(self): 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( obj = self.env["account.move.make.netting"].with_context(
active_ids=[self.move_line_1.id] active_ids=[self.move_line_1.id]
) )
with self.assertRaises(ValidationError): with self.assertRaises(UserError):
wizard = obj.create( wizard = obj.create(
{ {
"move_line_ids": [(6, 0, [self.move_line_1.id])], "move_line_ids": [(6, 0, [self.move_line_1.id])],
"journal_id": self.miscellaneous_journal.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( obj = self.env["account.move.make.netting"].with_context(
active_ids=[self.move_line_1.id, self.move_line_3.id] active_ids=[self.move_line_1.id, self.move_line_3.id]
) )
with self.assertRaises(ValidationError): with self.assertRaises(UserError):
wizard = obj.create( wizard = obj.create(
{ {
"move_line_ids": [ "move_line_ids": [
@ -130,11 +138,11 @@ class TestAccountNetting(common.TransactionCase):
"journal_id": self.miscellaneous_journal.id, "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( obj = self.env["account.move.make.netting"].with_context(
active_ids=[self.move_line_4.id, self.move_line_5.id] active_ids=[self.move_line_4.id, self.move_line_5.id]
) )
with self.assertRaises(ValidationError): with self.assertRaises(UserError):
wizard = obj.create( wizard = obj.create(
{ {
"move_line_ids": [ "move_line_ids": [
@ -143,7 +151,7 @@ class TestAccountNetting(common.TransactionCase):
"journal_id": self.miscellaneous_journal.id, "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( moves = self.env["account.move.line"].browse(
[self.move_line_4.id, self.move_line_5.id] [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( obj = self.env["account.move.make.netting"].with_context(
active_ids=[self.move_line_4.id, self.move_line_5.id] active_ids=[self.move_line_4.id, self.move_line_5.id]
) )
with self.assertRaises(ValidationError): with self.assertRaises(UserError):
wizard = obj.create( wizard = obj.create(
{ {
"move_line_ids": [ "move_line_ids": [
@ -160,11 +168,11 @@ class TestAccountNetting(common.TransactionCase):
"journal_id": self.miscellaneous_journal.id, "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( obj = self.env["account.move.make.netting"].with_context(
active_ids=[self.move_line_1.id, self.move_line_6.id] active_ids=[self.move_line_1.id, self.move_line_6.id]
) )
with self.assertRaises(ValidationError): with self.assertRaises(UserError):
wizard = obj.create( wizard = obj.create(
{ {
"move_line_ids": [ "move_line_ids": [
@ -182,6 +190,8 @@ class TestAccountNetting(common.TransactionCase):
"journal_id": self.miscellaneous_journal.id, "journal_id": self.miscellaneous_journal.id,
} }
) )
self.assertEqual(wizard.partner_id, self.partner1)
self.assertEqual(wizard.company_id, self.company)
res = wizard.button_compensate() res = wizard.button_compensate()
move = self.env["account.move"].browse(res["res_id"]) move = self.env["account.move"].browse(res["res_id"])
self.assertEqual( self.assertEqual(

View File

@ -2,20 +2,29 @@
# Copyright 2017 Tecnativa - Vicent Cubells # Copyright 2017 Tecnativa - Vicent Cubells
# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html # 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): class AccountMoveMakeNetting(models.TransientModel):
_name = "account.move.make.netting" _name = "account.move.make.netting"
_description = "Wizard to generate account moves for 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( journal_id = fields.Many2one(
comodel_name="account.journal", comodel_name="account.journal",
required=True, 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") move_line_ids = fields.Many2many(
balance = fields.Float(readonly=True) 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( balance_type = fields.Selection(
selection=[("pay", "To pay"), ("receive", "To receive")], selection=[("pay", "To pay"), ("receive", "To receive")],
readonly=True, readonly=True,
@ -24,54 +33,79 @@ class AccountMoveMakeNetting(models.TransientModel):
@api.model @api.model
def default_get(self, fields_list): def default_get(self, fields_list):
if len(self.env.context.get("active_ids", [])) < 2: if len(self.env.context.get("active_ids", [])) < 2:
raise exceptions.ValidationError( raise UserError(_("You should compensate at least 2 journal items."))
_("You should compensate at least 2 journal entries.")
)
move_lines = self.env["account.move.line"].browse( move_lines = self.env["account.move.line"].browse(
self.env.context["active_ids"] self.env.context["active_ids"]
) )
if any( partners = self.env["res.partner"]
x not in ("payable", "receivable") for line in move_lines:
for x in move_lines.mapped("account_id.user_type_id.type") if line.parent_state != "posted":
): raise UserError(_("Line '%s' is not posted.") % line.display_name)
raise exceptions.ValidationError( if line.account_id.account_type not in (
_("All entries must have a receivable or payable account") "liability_payable",
) "asset_receivable",
if any(move_lines.mapped("reconciled")): ):
raise exceptions.ValidationError(_("All entries mustn't been reconciled")) raise UserError(
if len(move_lines.mapped("account_id")) == 1: _(
raise exceptions.ValidationError( "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 " "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 " "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: if len(partners) != 1:
raise exceptions.ValidationError( raise UserError(
_( _(
"All entries should have a partner and the partner must " "The selected journal items have different partners: %s. "
"be the same for all." "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 = 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") debit_move_lines_debit = move_lines.filtered("debit")
credit_move_lines_debit = move_lines.filtered("credit") credit_move_lines_debit = move_lines.filtered("credit")
balance = abs(sum(debit_move_lines_debit.mapped("amount_residual"))) - abs( balance = ccur.round(
sum(credit_move_lines_debit.mapped("amount_residual")) 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 return res
def button_compensate(self): def _prepare_account_move(self):
self.ensure_one()
# Create account move
move = self.env["account.move"].create(
{"ref": _("AR/AP netting"), "journal_id": self.journal_id.id}
)
# Group amounts by account # Group amounts by account
account_groups = self.move_line_ids.read_group( account_groups = self.move_line_ids.read_group(
[("id", "in", self.move_line_ids.ids)], [("id", "in", self.move_line_ids.ids)],
@ -80,44 +114,50 @@ class AccountMoveMakeNetting(models.TransientModel):
) )
debtors = [] debtors = []
creditors = [] creditors = []
total_debtors = 0 total_debtors = 0.0
total_creditors = 0 total_creditors = 0.0
ccur = self.company_id.currency_id
for account_group in account_groups: for account_group in account_groups:
balance = account_group["amount_residual"] balance = account_group["amount_residual"]
group_vals = { group_vals = {
"account_id": account_group["account_id"][0], "account_id": account_group["account_id"][0],
"balance": abs(balance), "balance": abs(balance),
} }
if balance > 0: if ccur.compare_amounts(balance, 0) > 0:
debtors.append(group_vals) debtors.append(group_vals)
total_debtors += balance total_debtors += balance
else: else:
creditors.append(group_vals) creditors.append(group_vals)
total_creditors += abs(balance) total_creditors += abs(balance)
# Create move lines # Compute move lines
netting_amount = min(total_creditors, total_debtors) netting_amount = min(total_creditors, total_debtors)
field_map = {1: "debit", 0: "credit"} field_map = {1: "debit", 0: "credit"}
move_lines = [] move_lines = []
for i, group in enumerate([debtors, creditors]): for i, group in enumerate([debtors, creditors]):
available_amount = netting_amount available_amount = netting_amount
for account_group in group: for account_group in group:
if account_group["balance"] > available_amount:
amount = available_amount
else:
amount = account_group["balance"]
move_line_vals = { move_line_vals = {
field_map[i]: amount, field_map[i]: min(available_amount, account_group["balance"]),
"partner_id": self.move_line_ids[0].partner_id.id, "partner_id": self.partner_id.id,
"name": move.ref,
"account_id": account_group["account_id"], "account_id": account_group["account_id"],
} }
move_lines.append((0, 0, move_line_vals)) move_lines.append((0, 0, move_line_vals))
available_amount -= account_group["balance"] available_amount -= account_group["balance"]
if available_amount <= 0: if ccur.compare_amounts(available_amount, 0) <= 0:
break break
if move_lines: vals = {
move.write({"line_ids": move_lines}) "ref": _("AR/AP netting"),
move.action_post() "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 # Make reconciliation
for move_line in move.line_ids: for move_line in move.line_ids:
to_reconcile = move_line + self.move_line_ids.filtered( to_reconcile = move_line + self.move_line_ids.filtered(
@ -125,11 +165,15 @@ class AccountMoveMakeNetting(models.TransientModel):
) )
to_reconcile.reconcile() to_reconcile.reconcile()
# Open created move # 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" "account.action_move_journal_line"
) )
action["view_mode"] = "form" action.update(
del action["views"] {
del action["view_id"] "view_mode": "form",
action["res_id"] = move.id "views": False,
"view_id": False,
"res_id": move.id,
}
)
return action return action

View File

@ -13,10 +13,13 @@
<field name="name">Compensate entries</field> <field name="name">Compensate entries</field>
<field name="model">account.move.make.netting</field> <field name="model">account.move.make.netting</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<form string="Compensate entries"> <form>
<p <p
>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.</p> >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.</p>
<group> <group>
<field name="company_id" invisible="1" />
<field name="company_currency_id" invisible="1" />
<field name="partner_id" />
<field name="balance" /> <field name="balance" />
<field <field
name="balance_type" name="balance_type"

View File

@ -0,0 +1 @@
../../../../account_netting

View File

@ -0,0 +1,6 @@
import setuptools
setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)