odoo18/addons/account/tests/test_account_analytic.py

1115 lines
49 KiB
Python

from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.addons.analytic.tests.common import AnalyticCommon
from odoo.tests import tagged, Form
from odoo.exceptions import UserError, ValidationError
from odoo import Command
@tagged('post_install', '-at_install')
class TestAccountAnalyticAccount(AccountTestInvoicingCommon, AnalyticCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company_data_2 = cls.setup_other_company()
cls.env.user.groups_id += cls.env.ref('analytic.group_analytic_accounting')
# By default, tests are run with the current user set on the first company.
cls.env.user.company_id = cls.company_data['company']
cls.default_plan = cls.env['account.analytic.plan'].create({'name': 'Default'})
cls.analytic_account_a = cls.env['account.analytic.account'].create({
'name': 'analytic_account_a',
'plan_id': cls.default_plan.id,
'company_id': False,
})
cls.analytic_account_b = cls.env['account.analytic.account'].create({
'name': 'analytic_account_b',
'plan_id': cls.default_plan.id,
'company_id': False,
})
cls.analytic_account_d = cls.env['account.analytic.account'].create({
'name': 'analytic_account_d',
'plan_id': cls.default_plan.id,
'company_id': False,
})
cls.cross_plan = cls.env['account.analytic.plan'].create({'name': 'Cross'})
cls.analytic_account_5 = cls.env['account.analytic.account'].create({
'name': 'analytic_account_5',
'plan_id': cls.cross_plan.id,
'company_id': False,
})
def get_analytic_lines(self, invoice):
return self.env['account.analytic.line'].search([
('move_line_id', 'in', invoice.line_ids.ids),
]).sorted('amount')
def create_invoice(self, partner, product):
return self.env['account.move'].create([{
'move_type': 'out_invoice',
'partner_id': partner.id,
'date': '2017-01-01',
'invoice_date': '2017-01-01',
'invoice_line_ids': [Command.create({
'product_id': product.id,
})]
}])
def test_changing_analytic_company(self):
""" Ensure you can't change the company of an account.analytic.account if there are analytic lines linked to
the account
"""
self.env['account.analytic.line'].create({
'name': 'company specific account',
'account_id': self.analytic_account_3.id,
'amount': 100,
})
# Set a different company on the analytic account.
with self.assertRaises(UserError), self.cr.savepoint():
self.analytic_account_3.company_id = self.company_data_2['company']
# Making the analytic account not company dependent is allowed.
self.analytic_account_3.company_id = False
def test_analytic_lines(self):
''' Ensures analytic lines are created when posted and are recreated when editing the account.move'''
out_invoice = self.env['account.move'].create([{
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'date': '2017-01-01',
'invoice_date': '2017-01-01',
'invoice_line_ids': [Command.create({
'product_id': self.product_a.id,
'price_unit': 200.0,
'analytic_distribution': {
self.analytic_account_3.id: 100,
self.analytic_account_4.id: 50,
},
})]
}])
out_invoice.action_post()
# Analytic lines are created when posting the invoice
self.assertRecordValues(self.get_analytic_lines(out_invoice), [{
'amount': 100,
self.analytic_plan_2._column_name(): self.analytic_account_4.id,
'partner_id': self.partner_a.id,
'product_id': self.product_a.id,
}, {
'amount': 200,
self.analytic_plan_2._column_name(): self.analytic_account_3.id,
'partner_id': self.partner_a.id,
'product_id': self.product_a.id,
}])
# Analytic lines are updated when a posted invoice's distribution changes
out_invoice.invoice_line_ids.analytic_distribution = {
self.analytic_account_3.id: 100,
self.analytic_account_4.id: 25,
}
self.assertRecordValues(self.get_analytic_lines(out_invoice), [{
'amount': 50,
self.analytic_plan_2._column_name(): self.analytic_account_4.id,
}, {
'amount': 200,
self.analytic_plan_2._column_name(): self.analytic_account_3.id,
}])
# Analytic lines are deleted when resetting to draft
out_invoice.button_draft()
self.assertFalse(self.get_analytic_lines(out_invoice))
def test_analytic_lines_rounding(self):
""" Ensures analytic lines rounding errors are spread across all lines, in such a way that summing them gives the right amount.
For example, when distributing 100% of the the price, the sum of analytic lines should be exactly equal to the price. """
# in this scenario,
# 94% of 182.25 = 171.315 rounded to 171.32
# 2% of 182.25 = 3.645 rounded to 3.65
# 3 * 3.65 + 171.32 = 182.27
# we remove 0.01 to two lines to counter the rounding errors.
out_invoice = self.env['account.move'].create([{
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'date': '2017-01-01',
'invoice_date': '2017-01-01',
'invoice_line_ids': [Command.create({
'product_id': self.product_a.id,
'price_unit': 182.25,
'analytic_distribution': {
self.analytic_account_a.id: 94,
self.analytic_account_b.id: 2,
self.analytic_account_5.id: 2,
self.analytic_account_d.id: 2,
},
})]
}])
out_invoice.action_post()
self.assertRecordValues(self.get_analytic_lines(out_invoice), [
{
'amount': 3.64,
self.default_plan._column_name(): self.analytic_account_b.id,
self.cross_plan._column_name(): None,
},
{
'amount': 3.65,
self.default_plan._column_name(): self.analytic_account_d.id,
self.cross_plan._column_name(): None,
},
{
'amount': 3.65,
self.default_plan._column_name(): None,
self.cross_plan._column_name(): self.analytic_account_5.id,
},
{
'amount': 171.31,
self.default_plan._column_name(): self.analytic_account_a.id,
self.cross_plan._column_name(): None,
},
])
out_invoice.button_draft()
# in this scenario,
# 25% of 182.25 = 45.5625 rounded to 45.56
# 45.56 * 4 = 182.24
# we add 0.01 to one of the line to counter the rounding errors.
out_invoice.invoice_line_ids[0].analytic_distribution = {
self.analytic_account_a.id: 25,
self.analytic_account_b.id: 25,
self.analytic_account_5.id: 25,
self.analytic_account_d.id: 25,
}
out_invoice.action_post()
self.assertRecordValues(self.get_analytic_lines(out_invoice), [
{
'amount': 45.56,
self.default_plan._column_name(): self.analytic_account_d.id,
self.cross_plan._column_name(): None,
},
{
'amount': 45.56,
self.default_plan._column_name(): None,
self.cross_plan._column_name(): self.analytic_account_5.id,
},
{
'amount': 45.56,
self.default_plan._column_name(): self.analytic_account_b.id,
self.cross_plan._column_name(): None,
},
{
'amount': 45.57,
self.default_plan._column_name(): self.analytic_account_a.id,
self.cross_plan._column_name(): None,
},
])
def test_model_score(self):
"""Test that the models are applied correctly based on the score"""
self.env['account.analytic.distribution.model'].create([{
'product_id': self.product_a.id,
'analytic_distribution': {self.analytic_account_3.id: 100}
}, {
'partner_id': self.partner_a.id,
'product_id': self.product_a.id,
'analytic_distribution': {self.analytic_account_4.id: 100}
}])
# Partner and product match, score 2
invoice = self.create_invoice(self.partner_a, self.product_a)
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {str(self.analytic_account_4.id): 100})
# Match the partner but not the product, score 0
invoice = self.create_invoice(self.partner_a, self.product_b)
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, False)
# Product match, score 1
invoice = self.create_invoice(self.partner_b, self.product_a)
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {str(self.analytic_account_3.id): 100})
# No rule match with the product, score 0
invoice = self.create_invoice(self.partner_b, self.product_b)
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, False)
def test_model_application(self):
"""Test that the distribution is recomputed if and only if it is needed when changing the partner."""
self.env['account.analytic.distribution.model'].create([{
'partner_id': self.partner_a.id,
'analytic_distribution': {self.analytic_account_3.id: 100},
'company_id': False,
}, {
'partner_id': self.partner_b.id,
'analytic_distribution': {self.analytic_account_4.id: 100},
'company_id': False,
}])
invoice = self.create_invoice(self.env['res.partner'], self.product_a)
# No model is found, don't put anything
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, False)
# A model is found, set the new values
invoice.partner_id = self.partner_a
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {str(self.analytic_account_3.id): 100})
# A model is found, set the new values
invoice.partner_id = self.partner_b
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {str(self.analytic_account_4.id): 100})
# No model is found, don't change previously set values
invoice.partner_id = invoice.company_id.partner_id
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {str(self.analytic_account_4.id): 100})
# No model is found, don't change previously set values
invoice.partner_id = False
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {str(self.analytic_account_4.id): 100})
# It manual value is not erased in form view when saving
with Form(invoice) as invoice_form:
invoice_form.partner_id = self.partner_a
with invoice_form.invoice_line_ids.edit(0) as line_form:
self.assertEqual(line_form.analytic_distribution, {str(self.analytic_account_3.id): 100})
line_form.analytic_distribution = {self.analytic_account_4.id: 100}
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {str(self.analytic_account_4.id): 100})
def test_mandatory_plan_validation(self):
invoice = self.create_invoice(self.partner_b, self.product_a)
self.analytic_plan_2.write({
'applicability_ids': [Command.create({
'business_domain': 'invoice',
'product_categ_id': self.product_a.categ_id.id,
'applicability': 'mandatory',
})]
})
# ValidationError is raised only when validate_analytic is in the context and the distribution is != 100
with self.assertRaisesRegex(ValidationError, '100% analytic distribution.'):
invoice.with_context({'validate_analytic': True}).action_post()
invoice.invoice_line_ids.analytic_distribution = {self.analytic_account_4.id: 100.01}
with self.assertRaisesRegex(ValidationError, '100% analytic distribution.'):
invoice.with_context({'validate_analytic': True}).action_post()
invoice.invoice_line_ids.analytic_distribution = {self.analytic_account_4.id: 99.9}
with self.assertRaisesRegex(ValidationError, '100% analytic distribution.'):
invoice.with_context({'validate_analytic': True}).action_post()
invoice.invoice_line_ids.analytic_distribution = {self.analytic_account_4.id: 100}
invoice.with_context({'validate_analytic': True}).action_post()
self.assertEqual(invoice.state, 'posted')
# reset and post without the validate_analytic context key
invoice.button_draft()
invoice.invoice_line_ids.analytic_distribution = {self.analytic_account_4.id: 0.9}
invoice.action_post()
self.assertEqual(invoice.state, 'posted')
def test_mandatory_plan_validation_mass_posting(self):
"""
In case of mass posting, we should still check for mandatory analytic plans. This may raise a RedirectWarning,
if more than one entry was selected for posting, or a ValidationError if only one entry was selected.
"""
invoice1 = self.create_invoice(self.partner_a, self.product_a)
invoice2 = self.create_invoice(self.partner_b, self.product_a)
self.analytic_plan_2.write({
'applicability_ids': [Command.create({
'business_domain': 'invoice',
'product_categ_id': self.product_a.categ_id.id,
'applicability': 'mandatory',
})]
})
vam = self.env['validate.account.move'].with_context({
'active_model': 'account.move',
'active_ids': [invoice1.id, invoice2.id],
'validate_analytic': True,
}).create({'force_post': True})
for invoices in [invoice1, invoice1 | invoice2]:
with self.subTest(invoices=invoices):
with self.assertRaises(Exception):
vam.validate_move()
self.assertTrue('posted' not in invoices.mapped('state'))
def test_cross_analytics_computing(self):
out_invoice = self.env['account.move'].create([{
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'date': '2017-01-01',
'invoice_date': '2017-01-01',
'invoice_line_ids': [Command.create({
'product_id': self.product_b.id,
'price_unit': 200.0,
'analytic_distribution': {
f'{self.analytic_account_3.id},{self.analytic_account_5.id}': 20,
f'{self.analytic_account_3.id},{self.analytic_account_4.id}': 80,
},
})]
}])
out_invoice.action_post()
in_invoice = self.env['account.move'].create([{
'move_type': 'in_invoice',
'partner_id': self.partner_b.id,
'date': '2017-01-01',
'invoice_date': '2017-01-01',
'invoice_line_ids': [
Command.create({
'product_id': self.product_a.id,
'price_unit': 200.0,
'analytic_distribution': {
f'{self.analytic_account_3.id},{self.analytic_account_4.id}': 100,
},
}),
Command.create({
'product_id': self.product_a.id,
'price_unit': 200.0,
'analytic_distribution': {
f'{self.analytic_account_3.id},{self.analytic_account_5.id}': 50,
self.analytic_account_4.id: 50,
},
})
]
}])
in_invoice.action_post()
self.analytic_account_3._compute_invoice_count()
self.assertEqual(self.analytic_account_3.invoice_count, 1)
self.analytic_account_3._compute_vendor_bill_count()
self.assertEqual(self.analytic_account_3.vendor_bill_count, 1)
def test_applicability_score(self):
""" Tests which applicability is chosen if several ones are valid """
applicability_without_company, applicability_with_company = self.env['account.analytic.applicability'].create([
{
'business_domain': 'invoice',
'product_categ_id': self.product_a.categ_id.id,
'applicability': 'mandatory',
'analytic_plan_id': self.analytic_plan_2.id,
'company_id': False,
},
{
'business_domain': 'invoice',
'applicability': 'unavailable',
'analytic_plan_id': self.analytic_plan_2.id,
'company_id': self.env.company.id,
},
])
applicability = self.analytic_plan_2._get_applicability(business_domain='invoice', company_id=self.env.company.id, product=self.product_a.id)
self.assertEqual(applicability, 'mandatory', "product takes precedence over company")
# If the model that asks for a validation does not have a company_id,
# the score shouldn't take into account the company of the applicability
score = applicability_without_company._get_score(business_domain='invoice', product=self.product_a.id)
self.assertEqual(score, 2)
score = applicability_with_company._get_score(business_domain='invoice', product=self.product_a.id)
self.assertEqual(score, 1)
def test_model_sequence(self):
plan_A, plan_B, plan_C = self.env['account.analytic.plan'].create([
{'name': "Plan A"},
{'name': "Plan B"},
{'name': "Plan C"},
])
aa_A1, aa_A2, aa_B1, aa_B3, aa_C2 = self.env['account.analytic.account'].create([
{'name': "A1", 'plan_id': plan_A.id},
{'name': "A2", 'plan_id': plan_A.id},
{'name': "B1", 'plan_id': plan_B.id},
{'name': "B3", 'plan_id': plan_B.id},
{'name': "C2", 'plan_id': plan_C.id},
])
m1, m2, m3 = self.env['account.analytic.distribution.model'].create([
{'account_prefix': '123', 'sequence': 10, 'analytic_distribution': {f'{aa_A1.id},{aa_B1.id}': 100}},
{'account_prefix': '123', 'sequence': 20, 'analytic_distribution': {f'{aa_A2.id},{aa_C2.id}': 100}},
{'account_prefix': '123', 'sequence': 30, 'analytic_distribution': {f'{aa_B3.id}': 100}},
])
criteria = {
'account_prefix': '123456',
'company_id': self.env.company.id,
}
# Priority: m1 > m2 > m3 : A1, B1
distribution = self.env['account.analytic.distribution.model']._get_distribution(criteria)
self.assertEqual(distribution, m1.analytic_distribution, 'm1 fills A & B, ignore m1 & m2')
# Priority: m2 > m1 > m3 : A2, B3, C2
m1.sequence, m2.sequence, m3.sequence = 2, 1, 3
distribution = self.env['account.analytic.distribution.model']._get_distribution(criteria)
self.assertEqual(distribution, m2.analytic_distribution | m3.analytic_distribution, 'm2 fills A, ignore m1')
# Priority: m3 > m1 > m2 : A2, B3, C2
m1.sequence, m2.sequence, m3.sequence = 2, 3, 1
distribution = self.env['account.analytic.distribution.model']._get_distribution(criteria)
self.assertEqual(distribution, m2.analytic_distribution | m3.analytic_distribution, 'm3 fills B, ignore m1')
def test_analytic_distribution_multiple_prefixes(self):
self.env['account.analytic.distribution.model'].create([{
'account_prefix': '61;62',
'analytic_distribution': {self.analytic_account_3.id: 100},
'company_id': False,
}, {
'account_prefix': '63, 64',
'analytic_distribution': {self.analytic_account_4.id: 100},
'company_id': False,
}])
accounts_by_code = self.env['account.account'].search([('code', 'ilike', '6%')]).grouped('code')
invoice = self.create_invoice(self.env['res.partner'], self.product_a)
# No model is found, don't put anything
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, False)
invoice.invoice_line_ids.account_id = accounts_by_code.get('611000')
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {str(self.analytic_account_3.id): 100})
invoice.invoice_line_ids.account_id = accounts_by_code.get('630000')
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {str(self.analytic_account_4.id): 100})
invoice.invoice_line_ids.account_id = accounts_by_code.get('620000')
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {str(self.analytic_account_3.id): 100})
invoice.invoice_line_ids.account_id = accounts_by_code.get('641000')
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {str(self.analytic_account_4.id): 100})
def test_analytic_applicability_multiple_prefixes(self):
# This applicability should block all invoices with lines having account_code who starts with '40' or '41'
self.env['account.analytic.applicability'].create([
{
'business_domain': 'invoice',
'applicability': 'mandatory',
'analytic_plan_id': self.analytic_plan_2.id,
'company_id': self.env.company.id,
'account_prefix': '40, 41',
}
])
account_analytic = self.env['account.analytic.account'].search([('plan_id', '=', self.analytic_plan_2.id)], limit=1)
account_invoice_1, account_invoice_2 = self.env['account.account'].create([
{
'code': '400300',
'name': 'My first invoice Account',
},
{
'code': '410300',
'name': 'My second invoice Account',
},
])
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.partner_b.id,
'date': '2017-01-01',
'invoice_date': '2017-01-01',
'invoice_line_ids': [
Command.create({
'product_id': self.product_a.id,
'price_unit': 200.0,
'account_id': account_invoice_1.id,
}),
Command.create({
'product_id': self.product_a.id,
'price_unit': 200.0,
'account_id': account_invoice_2.id,
})
]
})
# This invoice should be blocked as there is no analytic plans on lines
with self.assertRaisesRegex(ValidationError, '100% analytic distribution.'):
invoice.with_context({'validate_analytic': True}).action_post()
invoice.line_ids.filtered(lambda line: line.account_id.code.startswith('40'))[0].write({
'analytic_distribution': {account_analytic.id: 100}
})
# This invoice should be blocked because one line is missing plans
with self.assertRaisesRegex(ValidationError, '100% analytic distribution.'):
invoice.with_context({'validate_analytic': True}).action_post()
invoice.line_ids.filtered(lambda line: line.account_id.code.startswith('41'))[0].write({
'analytic_distribution': {account_analytic.id: 100}
})
# This invoice should not be blocked, as all lines have plans
invoice.with_context({'validate_analytic': True}).action_post()
def test_analytic_lines_partner_compute(self):
''' Ensures analytic lines partner is changed when changing partner on move line'''
def get_analytic_lines():
return self.env['account.analytic.line'].search([
('move_line_id', 'in', entry.line_ids.ids)
]).sorted('amount')
entry = self.env['account.move'].create([{
'move_type': 'entry',
'partner_id': self.partner_a.id,
'line_ids': [
Command.create({
'account_id': self.company_data['default_account_receivable'].id,
'debit': 200.0,
'partner_id': self.partner_a.id,
}),
Command.create({
'account_id': self.company_data['default_account_revenue'].id,
'credit': 200.0,
'partner_id': self.partner_b.id,
'analytic_distribution': {
self.analytic_account_1.id: 100,
},
}),
]
}])
entry.action_post()
# Analytic lines are created when posting the invoice
analytic_line = get_analytic_lines()
self.assertRecordValues(analytic_line, [{
'amount': 200,
self.analytic_plan_1._column_name(): self.analytic_account_1.id,
'partner_id': self.partner_b.id,
}])
# Change the move line on the analytic line, partner changes on the analytic line
analytic_line.move_line_id = entry.line_ids[0]
self.assertRecordValues(analytic_line, [{
'amount': 200,
self.analytic_plan_1._column_name(): self.analytic_account_1.id,
'partner_id': self.partner_a.id,
}])
# Change the move line's partner, partner changes on the analytic line
entry.line_ids.write({'partner_id': self.partner_b.id})
self.assertRecordValues(analytic_line, [{
'amount': 200,
self.analytic_plan_1._column_name(): self.analytic_account_1.id,
'partner_id': self.partner_b.id,
}])
def test_tax_line_sync_with_analytic(self):
"""
Test that the line syncs, especially the tax line, keep the analytic distribution when saving the move
"""
account_with_tax = self.company_data['default_account_revenue'].copy({'tax_ids': [Command.set(self.company_data['default_tax_sale'].ids)]})
move = self.env['account.move'].create({
'move_type': 'entry',
'line_ids': [Command.create({'account_id': account_with_tax.id, 'debit': 100})]
})
move.line_ids.write({'analytic_distribution': {self.analytic_account_1.id: 100}})
self.assertRecordValues(move.line_ids.sorted('balance'), [
{
'name': 'Automatic Balancing Line',
'analytic_distribution': {str(self.analytic_account_1.id): 100.00},
},
{
'name': self.company_data['default_tax_sale'].name,
'analytic_distribution': {str(self.analytic_account_1.id): 100.00},
},
{
'name': False,
'analytic_distribution': {str(self.analytic_account_1.id): 100.00},
},
])
def test_get_relevant_plans_in_multi_company(self):
""" Test the plans returned with applicability rules and options in multi-company """
self.analytic_plan_1.write({
'applicability_ids': [Command.create({
'business_domain': 'general',
'applicability': 'mandatory',
'account_prefix': '60, 61, 62',
})],
})
company_2 = self.company_data_2['company']
plans_json = self.env['account.analytic.plan'].sudo().with_company(company_2).get_relevant_plans(
business_domain='general',
account=self.company_data['default_account_assets'].id,
company=self.company.id,
)
self.assertTrue(plans_json)
def test_analytic_distribution_with_discount(self):
"""Ensure that discount lines include analytic distribution when a discount expense account is set."""
# Create discount expense account
self.company_data['company'].account_discount_expense_allocation_id = self.env['account.account'].create({
'name': 'Discount Expense',
'code': 'DIS',
'account_type': 'expense',
'reconcile': False,
})
# Create invoice with 2 lines: each has a discount and analytic distribution
out_invoice = self.env['account.move'].create([{
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'date': '2017-01-01',
'invoice_date': '2017-01-01',
'invoice_line_ids': [Command.create({
'product_id': self.product_a.id,
'tax_ids': [Command.clear()],
'price_unit': 200.0,
'discount': 20, # 40.0 discount
'analytic_distribution': {
self.analytic_account_1.id: 100,
},
}), Command.create({
'product_id': self.product_b.id,
'tax_ids': [Command.clear()],
'price_unit': 200.0,
'discount': 10, # 20.0 discount
'analytic_distribution': {
self.analytic_account_2.id: 100,
},
})]
}])
out_invoice.action_post()
self.assertRecordValues(out_invoice.line_ids, [{
'display_type': 'product',
'balance': -160.0,
'analytic_distribution': {str(self.analytic_account_1.id): 100},
}, {
'display_type': 'product',
'balance': -180.0,
'analytic_distribution': {str(self.analytic_account_2.id): 100},
}, {
'display_type': 'discount',
'balance': -40.0,
'analytic_distribution': {str(self.analytic_account_1.id): 100}
}, {
'display_type': 'discount',
'balance': 60.0,
'analytic_distribution': {
str(self.analytic_account_1.id): 66.67,
str(self.analytic_account_2.id): 33.33,
}
}, {
'display_type': 'discount',
'balance': -20.0,
'analytic_distribution': {str(self.analytic_account_2.id): 100}
}, {
'display_type': 'payment_term',
'balance': 340.0,
'analytic_distribution': False,
}])
def test_synchronization_between_analytic_distribution_and_analytic_lines(self):
""" Test creating, updating, and deleting analytic lines and ensure the changes are reflected in move_line's analytic_distribution. """
# Create an invoice with analytic distribution
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'date': '2023-01-01',
'invoice_date': '2023-01-01',
'invoice_line_ids': [
Command.create({
'product_id': self.product_a.id,
'price_unit': 100.0,
'analytic_distribution': {
self.analytic_account_1.id: 40,
self.analytic_account_2.id: 60,
},
}),
],
})
# Post the invoice
invoice.action_post()
# Fetch the associated move line and analytic lines
invoice_line = invoice.invoice_line_ids
analytic_lines = invoice_line.analytic_line_ids.sorted('amount')
# Update the account of the first analytic line
analytic_lines[0].write({
self.analytic_account_3.plan_id._column_name(): self.analytic_account_3.id,
'amount': 50,
})
self.assertEqual(invoice_line.analytic_distribution, {
f"{self.analytic_account_1.id},{self.analytic_account_3.id}": 50,
f"{self.analytic_account_2.id}": 60,
})
# Delete the first analytic line
analytic_lines[0].unlink()
self.assertEqual(invoice_line.analytic_distribution, {
f"{self.analytic_account_2.id}": 60,
})
# Create analytic line
self.env['account.analytic.line'].create({
'name': 'Extra Analytic Line',
'account_id': self.analytic_account_1.id,
'amount': 30,
'move_line_id': invoice_line.id,
})
self.assertEqual(invoice_line.analytic_distribution, {
f"{self.analytic_account_1.id}": 30,
f"{self.analytic_account_2.id}": 60,
})
# Unlink from a move line
analytic_lines = invoice.invoice_line_ids.analytic_line_ids
analytic_lines.move_line_id = False
self.assertFalse(invoice_line.analytic_distribution)
# Link to a move line
analytic_lines.move_line_id = invoice_line
self.assertEqual(invoice_line.analytic_distribution, {
f"{self.analytic_account_1.id}": 30,
f"{self.analytic_account_2.id}": 60,
})
def test_zero_balance_invoice_with_analytic_line(self):
""" Test that creating an analytic line on a 0-amount invoice does not crash and updates analytic_distribution safely. """
self.product_a.list_price = 0.0
invoice = self.create_invoice(self.partner_a, self.product_a)
invoice.action_post()
self.env['account.analytic.line'].create({
'name': 'Zero Balance Test',
'account_id': self.analytic_account_1.id,
'amount': 33.0,
'move_line_id': invoice.invoice_line_ids.id,
})
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {f"{self.analytic_account_1.id}": 100.0})
def test_analytic_dynamic_update(self):
plan1 = self.analytic_account_1.plan_id._column_name()
plan2 = self.analytic_account_3.plan_id._column_name()
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'date': '2023-01-01',
'invoice_date': '2023-01-01',
'invoice_line_ids': [
Command.create({
'product_id': self.product_a.id,
'price_unit': 100.0,
'analytic_distribution': {
self.analytic_account_1.id: 40,
self.analytic_account_2.id: 60,
},
}),
],
})
invoice_line = invoice.invoice_line_ids
for comment, init, update, expect in [(
"Add a distribution on a previously empty plan",
{
f"{self.analytic_account_1.id}": 40,
f"{self.analytic_account_2.id}": 60,
}, {
'__update__': [plan2],
f"{self.analytic_account_3.id}": 25,
f"{self.analytic_account_4.id}": 75,
}, {
f"{self.analytic_account_1.id},{self.analytic_account_3.id}": 10,
f"{self.analytic_account_2.id},{self.analytic_account_3.id}": 15,
f"{self.analytic_account_1.id},{self.analytic_account_4.id}": 30,
f"{self.analytic_account_2.id},{self.analytic_account_4.id}": 45,
},
), (
"Add a distribution on a previously empty plan, both less than 100%",
{
f"{self.analytic_account_1.id}": 20,
f"{self.analytic_account_2.id}": 30,
}, {
'__update__': [plan2],
f"{self.analytic_account_3.id}": 10,
f"{self.analytic_account_4.id}": 40,
}, {
f"{self.analytic_account_1.id},{self.analytic_account_3.id}": 4,
f"{self.analytic_account_2.id},{self.analytic_account_3.id}": 6,
f"{self.analytic_account_1.id},{self.analytic_account_4.id}": 16,
f"{self.analytic_account_2.id},{self.analytic_account_4.id}": 24,
},
), (
"Add a distribution on a previously empty plan, both more than 100%",
{
f"{self.analytic_account_1.id}": 200,
f"{self.analytic_account_2.id}": 300,
}, {
'__update__': [plan2],
f"{self.analytic_account_3.id}": 100,
f"{self.analytic_account_4.id}": 400,
}, {
f"{self.analytic_account_1.id},{self.analytic_account_3.id}": 40,
f"{self.analytic_account_2.id},{self.analytic_account_3.id}": 60,
f"{self.analytic_account_1.id},{self.analytic_account_4.id}": 160,
f"{self.analytic_account_2.id},{self.analytic_account_4.id}": 240,
},
), (
"Update the percentage of one plan without changing the other",
{
f"{self.analytic_account_1.id},{self.analytic_account_3.id}": 10,
f"{self.analytic_account_2.id},{self.analytic_account_3.id}": 15,
f"{self.analytic_account_1.id},{self.analytic_account_4.id}": 30,
f"{self.analytic_account_2.id},{self.analytic_account_4.id}": 45,
}, {
'__update__': [plan1, plan2],
f"{self.analytic_account_1.id},{self.analytic_account_3.id}": 15,
f"{self.analytic_account_2.id},{self.analytic_account_3.id}": 10,
f"{self.analytic_account_1.id},{self.analytic_account_4.id}": 45,
f"{self.analytic_account_2.id},{self.analytic_account_4.id}": 30,
}, {
f"{self.analytic_account_1.id},{self.analytic_account_3.id}": 15,
f"{self.analytic_account_2.id},{self.analytic_account_3.id}": 10,
f"{self.analytic_account_1.id},{self.analytic_account_4.id}": 45,
f"{self.analytic_account_2.id},{self.analytic_account_4.id}": 30,
},
), (
"Update the percentage on both plans at the same time",
{
f"{self.analytic_account_1.id},{self.analytic_account_3.id}": 10,
f"{self.analytic_account_2.id},{self.analytic_account_3.id}": 15,
f"{self.analytic_account_1.id},{self.analytic_account_4.id}": 30,
f"{self.analytic_account_2.id},{self.analytic_account_4.id}": 45,
}, {
'__update__': [plan1, plan2],
f"{self.analytic_account_1.id},{self.analytic_account_3.id}": 45,
f"{self.analytic_account_2.id},{self.analytic_account_3.id}": 30,
f"{self.analytic_account_1.id},{self.analytic_account_4.id}": 15,
f"{self.analytic_account_2.id},{self.analytic_account_4.id}": 10,
}, {
f"{self.analytic_account_1.id},{self.analytic_account_3.id}": 45,
f"{self.analytic_account_2.id},{self.analytic_account_3.id}": 30,
f"{self.analytic_account_1.id},{self.analytic_account_4.id}": 15,
f"{self.analytic_account_2.id},{self.analytic_account_4.id}": 10,
},
), (
"Remove everything set on plan 1",
{
f"{self.analytic_account_1.id},{self.analytic_account_3.id}": 45,
f"{self.analytic_account_2.id},{self.analytic_account_3.id}": 30,
f"{self.analytic_account_1.id},{self.analytic_account_4.id}": 15,
f"{self.analytic_account_2.id},{self.analytic_account_4.id}": 10,
}, {
'__update__': [plan1],
}, {
f"{self.analytic_account_3.id}": 75,
f"{self.analytic_account_4.id}": 25,
},
), (
"Nothing changes because there is nothing in __update__",
{
f"{self.analytic_account_1.id}": 40,
f"{self.analytic_account_2.id}": 60,
}, {
'__update__': [],
}, {
f"{self.analytic_account_1.id}": 40,
f"{self.analytic_account_2.id}": 60,
},
), (
"remove everything because __update__ is not set",
{
f"{self.analytic_account_1.id}": 40,
f"{self.analytic_account_2.id}": 60,
}, {
}, False,
), (
"Add a distribution on a previously empty plan, with more than 100%",
{
f"{self.analytic_account_1.id}": 40,
f"{self.analytic_account_2.id}": 60,
}, {
'__update__': [plan2],
f"{self.analytic_account_3.id}": 33,
f"{self.analytic_account_4.id}": 167,
}, {
f"{self.analytic_account_1.id},{self.analytic_account_3.id}": 6.6,
f"{self.analytic_account_1.id},{self.analytic_account_4.id}": 33.4,
f"{self.analytic_account_2.id},{self.analytic_account_3.id}": 9.9,
f"{self.analytic_account_2.id},{self.analytic_account_4.id}": 50.1,
f"{self.analytic_account_3.id}": 16.5,
f"{self.analytic_account_4.id}": 83.5,
},
), (
"Add a distribution on a previously empty plan, with previous values more than 100%",
{
f"{self.analytic_account_3.id}": 33,
f"{self.analytic_account_4.id}": 167,
}, {
'__update__': [plan1],
f"{self.analytic_account_1.id}": 40,
f"{self.analytic_account_2.id}": 60,
}, {
f"{self.analytic_account_3.id},{self.analytic_account_1.id}": 6.6,
f"{self.analytic_account_4.id},{self.analytic_account_1.id}": 33.4,
f"{self.analytic_account_3.id},{self.analytic_account_2.id}": 9.9,
f"{self.analytic_account_4.id},{self.analytic_account_2.id}": 50.1,
f"{self.analytic_account_3.id}": 16.5,
f"{self.analytic_account_4.id}": 83.5,
},
), (
"Add a distribution on a previously empty plan, with less than 100%",
{
f"{self.analytic_account_1.id}": 40,
f"{self.analytic_account_2.id}": 60,
}, {
'__update__': [plan2],
f"{self.analytic_account_3.id}": 20,
f"{self.analytic_account_4.id}": 30,
}, {
f"{self.analytic_account_1.id},{self.analytic_account_3.id}": 8,
f"{self.analytic_account_1.id},{self.analytic_account_4.id}": 12,
f"{self.analytic_account_2.id},{self.analytic_account_3.id}": 12,
f"{self.analytic_account_2.id},{self.analytic_account_4.id}": 18,
f"{self.analytic_account_1.id}": 20,
f"{self.analytic_account_2.id}": 30,
},
), (
"Add a distribution on a previously empty plan, with previous values less than 100%",
{
f"{self.analytic_account_3.id}": 20,
f"{self.analytic_account_4.id}": 30,
}, {
'__update__': [plan1],
f"{self.analytic_account_1.id}": 40,
f"{self.analytic_account_2.id}": 60,
}, {
f"{self.analytic_account_3.id},{self.analytic_account_1.id}": 8,
f"{self.analytic_account_4.id},{self.analytic_account_1.id}": 12,
f"{self.analytic_account_3.id},{self.analytic_account_2.id}": 12,
f"{self.analytic_account_4.id},{self.analytic_account_2.id}": 18,
f"{self.analytic_account_1.id}": 20,
f"{self.analytic_account_2.id}": 30,
},
)]:
with self.subTest(comment=comment):
invoice_line.analytic_distribution = init
invoice_line.flush_recordset(['analytic_distribution'])
invoice_line.analytic_distribution = update
self.assertEqual(invoice_line.analytic_distribution, expect)
def test_move_with_analytic_lines(self):
"""
Ensure that, if analytic lines are created when a move is in draft state (as happens when importing a move
with analytics), the analytic lines are unlinked. AMLs should still have the correct analytic distribution.
"""
# Create a move with commands to create analytic lines
journal_entry = self.env['account.move'].create({
'move_type': 'entry',
'line_ids': [
Command.create({
'name': 'debit',
'account_id': self.company_data['default_account_revenue'].id,
'debit': 2000.0,
'credit': 0.0,
'analytic_line_ids': [Command.create({
'name': 'Analytic Line 1',
'account_id': self.analytic_account_a.id,
'amount': -2000,
self.analytic_plan_1._column_name(): self.analytic_account_1.id,
self.analytic_plan_2._column_name(): self.analytic_account_3.id,
})],
}),
Command.create({
'name': 'credit',
'account_id': self.company_data['default_account_expense'].id,
'debit': 0.0,
'credit': 2000.0,
'analytic_line_ids': [Command.create({
'name': 'Analytic Line 2',
'account_id': self.analytic_account_a.id,
'amount': 2000.0,
self.analytic_plan_1._column_name(): False,
self.analytic_plan_2._column_name(): False,
})],
}),
],
})
# No analytic line should be created at this point
self.assertFalse(self.get_analytic_lines(journal_entry))
# Confirm that the analytic distribution was correctly set based on the analytic_line_ids values
self.assertRecordValues(journal_entry.line_ids, [
{'analytic_distribution': {
f"{self.analytic_account_a.id},{self.analytic_account_1.id},{self.analytic_account_3.id}": 100.0,
}},
{'analytic_distribution': {f"{self.analytic_account_a.id}": 100.0}},
])
# Write to an existing draft move, with a command to create analytic lines
journal_entry.line_ids[0].write({
'analytic_line_ids': [Command.create({
'name': 'Analytic Line 1',
'account_id': False,
'amount': -2000,
self.analytic_plan_1._column_name(): self.analytic_account_1.id,
self.analytic_plan_2._column_name(): False,
})],
})
# Still no analytic line
self.assertFalse(self.get_analytic_lines(journal_entry))
# Confirm that the analytic distribution is correct
self.assertRecordValues(journal_entry.line_ids, [
{'analytic_distribution': {f"{self.analytic_account_1.id}": 100.0}},
{'analytic_distribution': {f"{self.analytic_account_a.id}": 100.0}},
])
# After posting the move, the analytic line should be created as usual
journal_entry.action_post()
self.assertTrue(self.get_analytic_lines(journal_entry))
def test_analytic_lines_on_post(self):
"""
In some cases (e.g. when there is a purchase lock, the journal has autocheck_on_post=False),
when the move state is changed to post, write is first triggered for dependencies while the move
state is still 'draft'. In these cases, the analytic lines created should not be deleted.
"""
in_invoice = self.env['account.move'].create([{
'move_type': 'in_invoice',
'partner_id': self.partner_a.id,
'date': '2017-01-01',
'invoice_date': '2017-01-01',
'invoice_line_ids': [
Command.create({
'product_id': self.product_a.id,
'price_unit': 100.0,
'analytic_distribution': {self.analytic_account_1.id: 100},
}),
],
}])
in_invoice.company_id.purchase_lock_date = '2017-01-31'
in_invoice.action_post()
self.assertTrue(self.get_analytic_lines(in_invoice))
def test_multicurrency_different_rounding_analytic_line(self):
"""If using a foreign currency, the rounding of the analytic_line amount should the one from the company currency"""
foreign_currency = self.env['res.currency'].create({
'name': "Great Currency",
'symbol': '🫀',
'rounding': 1,
'rate_ids': [
Command.create({'name': '2025-01-01', 'rate': 3}),
],
})
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.partner_a.id,
'date': '2025-01-01',
'currency_id': foreign_currency.id,
'invoice_line_ids': [Command.create({
'product_id': self.product_a.id,
'price_unit': 10.0,
'quantity': 1,
'tax_ids': [],
'analytic_distribution': {
self.analytic_account_1.id: 100,
},
})]
})
invoice.action_post()
self.assertEqual(self.get_analytic_lines(invoice).amount, 3.33)