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)