19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:56 +01:00
parent a2f74aefd8
commit 4a4d12c333
844 changed files with 212348 additions and 270090 deletions

View file

@ -1,20 +1,34 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
from odoo import Command
from odoo.tests import tagged
from odoo.tools import float_round, float_compare
from odoo.tools import float_round
from odoo.addons.project.tests.test_project_profitability import TestProjectProfitabilityCommon
from odoo.addons.purchase.tests.test_purchase_invoice import TestPurchaseToInvoiceCommon
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tools.float_utils import float_compare
@tagged('-at_install', 'post_install')
class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurchaseToInvoiceCommon):
class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurchaseToInvoiceCommon, AccountTestInvoicingCommon):
def test_bills_without_purchase_order_are_accounted_in_profitability(self):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env.user.group_ids |= cls.env.ref('purchase.group_purchase_user')
cls.company_data_2 = cls.setup_other_company()
def _create_invoice_for_po(self, purchase_order):
purchase_order.action_create_invoice()
purchase_bill = purchase_order.invoice_ids # get the bill from the purchase
purchase_bill.invoice_date = datetime.today()
purchase_bill.action_post()
return purchase_bill
def test_bills_without_purchase_order_are_accounted_in_profitability_project_purchase(self):
"""
A bill that has an AAL on one of its line should be taken into account
for the profitability of the project.
@ -41,17 +55,32 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
"currency_id": self.env.company.currency_id.id,
})],
})
# the bill_1 is in draft, therefor it should have the cost "to_bill" same as the -product_price (untaxed)
# add 2 new AAL to the analytic account. Those costs must be present in the cost data
self.env['account.analytic.line'].create([{
'name': 'extra costs 1',
'account_id': self.analytic_account.id,
'amount': -50.1,
}, {
'name': 'extra costs 2',
'account_id': self.analytic_account.id,
'amount': -100,
}])
# the bill_1 is in draft, therefore it should have the cost "to_bill" same as the -product_price (untaxed)
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
{
'data': [{
'id': 'other_costs_aal',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_costs_aal'],
'to_bill': 0.0,
'billed': -150.1,
}, {
'id': 'other_purchase_costs',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'],
'to_bill': -self.product_a.standard_price * analytic_contribution,
'billed': 0.0,
}],
'total': {'to_bill': -self.product_a.standard_price * analytic_contribution, 'billed': 0.0},
'total': {'to_bill': -self.product_a.standard_price * analytic_contribution, 'billed': -150.1},
},
)
# post bill_1
@ -61,12 +90,17 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
self.project._get_profitability_items(False)['costs'],
{
'data': [{
'id': 'other_costs_aal',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_costs_aal'],
'to_bill': 0.0,
'billed': -150.1,
}, {
'id': 'other_purchase_costs',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'],
'to_bill': 0.0,
'billed': -self.product_a.standard_price * analytic_contribution,
}],
'total': {'to_bill': 0.0, 'billed': -self.product_a.standard_price * analytic_contribution},
'total': {'to_bill': 0.0, 'billed': -self.product_a.standard_price * analytic_contribution - 150.1},
},
)
# create another bill, with 3 lines, 2 diff products, the second line has 2 as quantity, the third line has a negative price
@ -99,11 +133,16 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
"currency_id": self.env.company.currency_id.id,
})],
})
# bill_2 is not posted, therefor its cost should be "to_billed" = - sum of all product_price * qty for each line
# bill_2 is not posted, therefore its cost should be "to_billed" = - sum of all product_price * qty for each line
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
{
'data': [{
'id': 'other_costs_aal',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_costs_aal'],
'to_bill': 0.0,
'billed': -150.1,
}, {
'id': 'other_purchase_costs',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'],
'to_bill': -(self.product_a.standard_price +
@ -115,17 +154,22 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
'to_bill': -(self.product_a.standard_price +
2 * self.product_b.standard_price -
self.service_deliver.standard_price) * analytic_contribution,
'billed': -self.product_a.standard_price * analytic_contribution,
'billed': -self.product_a.standard_price * analytic_contribution - 150.1,
},
},
)
# post bill_2
bill_2.action_post()
# bill_2 is posted, therefor its cost should be counting in "billed", with the cost of bill_1
# bill_2 is posted, therefore its cost should be counting in "billed", with the cost of bill_1
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
{
'data': [{
'id': 'other_costs_aal',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_costs_aal'],
'to_bill': 0.0,
'billed': -150.1,
}, {
'id': 'other_purchase_costs',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'],
'to_bill': 0.0,
@ -137,7 +181,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
'to_bill': 0.0,
'billed': -(2 * self.product_a.standard_price +
2 * self.product_b.standard_price -
self.service_deliver.standard_price) * analytic_contribution,
self.service_deliver.standard_price) * analytic_contribution - 150.1,
},
},
)
@ -154,15 +198,20 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
})],
})
purchase_order.button_confirm()
purchase_order.order_line.flush_recordset()
# we should have a new section "purchase_order", the total should be updated,
# but the "other_purchase_costs" shouldn't change, as we don't takes into
self.assertEqual(purchase_order.invoice_status, 'to invoice')
# The section "purchase_order" should appear as the purchase order is validated, the total should be updated,
# the "other_purchase_costs" shouldn't change, as we don't take into
# account bills from purchase orders, as those are already taken into calculations
# from the purchase orders (in "purchase_order" section)
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
{
'data': [{
'id': 'other_costs_aal',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_costs_aal'],
'to_bill': 0.0,
'billed': -150.1,
},{
'id': 'purchase_order',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['purchase_order'],
'to_bill': -self.product_order.standard_price * analytic_contribution,
@ -171,28 +220,68 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
'id': 'other_purchase_costs',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'],
'to_bill': 0.0,
'billed': float_round(-(2 * self.product_a.standard_price +
'billed': -(2 * self.product_a.standard_price +
2 * self.product_b.standard_price -
self.service_deliver.standard_price) * analytic_contribution, precision_digits=price_precision),
self.service_deliver.standard_price) * analytic_contribution,
}],
'total': {
'to_bill': -self.product_order.standard_price * analytic_contribution,
'billed': float_round(-(2 * self.product_a.standard_price +
'billed': -(2 * self.product_a.standard_price +
2 * self.product_b.standard_price -
self.service_deliver.standard_price) * analytic_contribution, precision_digits=price_precision),
self.service_deliver.standard_price) * analytic_contribution - 150.1,
},
},
)
# Create a vendor bill linked to the PO
purchase_order.action_create_invoice()
purchase_bill = purchase_order.invoice_ids # get the bill from the purchase
purchase_bill.invoice_date = datetime.today()
purchase_bill.action_post()
# now the bill has been posted, its costs should be accounted in the "billed" part
self.assertEqual(purchase_order.invoice_ids.state, 'draft')
# now the bill has been created and set to draft so the section "purchase_order" should appear, its costs should be accounted in the "to bill" part
# of the purchase_order section, but should touch in the other_purchase_costs
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
{
'data': [{
'id': 'other_costs_aal',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_costs_aal'],
'to_bill': 0.0,
'billed': -150.1,
}, {
'id': 'purchase_order',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['purchase_order'],
'to_bill': -self.product_order.standard_price * analytic_contribution,
'billed': 0.0,
}, {
'id': 'other_purchase_costs',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'],
'to_bill': 0.0,
'billed': -(2 * self.product_a.standard_price +
2 * self.product_b.standard_price -
self.service_deliver.standard_price) * analytic_contribution,
}],
'total': {
'to_bill': -self.product_order.standard_price * analytic_contribution,
'billed': -(2 * self.product_a.standard_price +
2 * self.product_b.standard_price -
self.service_deliver.standard_price) * analytic_contribution -150.1,
},
},
)
# Post the vendor bill linked to the PO
purchase_bill = purchase_order.invoice_ids
purchase_bill.invoice_date = datetime.today()
purchase_bill.action_post()
self.assertEqual(purchase_order.invoice_ids.state, 'posted')
# now the bill has been posted so the costs of the section "purchase_order" should be accounted in the "billed" part
# and the total should be updated accordingly
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
{
'data': [{
'id': 'other_costs_aal',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_costs_aal'],
'to_bill': 0.0,
'billed': -150.1,
}, {
'id': 'purchase_order',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['purchase_order'],
'to_bill': 0.0,
@ -207,10 +296,10 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
}],
'total': {
'to_bill': 0.0,
'billed': float_round(-(2 * self.product_a.standard_price +
'billed': -(2 * self.product_a.standard_price +
2 * self.product_b.standard_price -
self.service_deliver.standard_price +
self.product_order.standard_price) * analytic_contribution, precision_digits=price_precision),
self.product_order.standard_price) * analytic_contribution - 150.1,
},
},
)
@ -253,7 +342,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
})],
})
purchase_order.button_confirm()
purchase_order.order_line.flush_recordset()
self.assertEqual(purchase_order.invoice_status, 'to invoice')
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
{
@ -268,11 +357,12 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
'billed': 0.0,
},
},
'No data should be found since the purchase order is not invoiced.',
)
purchase_order.action_create_invoice()
purchase_bill = purchase_order.invoice_ids # get the bill from the purchase
purchase_bill.invoice_date = datetime.today()
purchase_bill.action_post()
# Invoice the purchase order
self._create_invoice_for_po(purchase_order)
self.assertEqual(purchase_order.invoice_status, 'invoiced')
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
{
@ -289,6 +379,237 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
},
)
def test_multi_currency_for_project_purchase_profitability(self):
""" This test ensures that when purchase orders with different currencies are linked to the same project, the amount are correctly computed according to the
rate of the company """
project = self.env['project.project'].create({'name': 'new project'})
project._create_analytic_account()
account = project.account_id
foreign_company = self.company_data_2['company']
foreign_company.currency_id = self.foreign_currency
# a custom analytic contribution (number between 1 -> 100 included)
analytic_distribution = 42
analytic_contribution = analytic_distribution / 100.
# Create a bill_1 with the foreign_currency.
bill_1 = self.env['account.move'].create({
"name": "Bill foreign currency",
"move_type": "in_invoice",
"state": "draft",
"partner_id": self.partner.id,
"invoice_date": datetime.today(),
"date": datetime.today(),
"invoice_date_due": datetime.today() - timedelta(days=1),
"company_id": foreign_company.id,
"invoice_line_ids": [Command.create({
"analytic_distribution": {account.id: analytic_distribution},
"product_id": self.product_a.id,
"quantity": 1,
"product_uom_id": self.product_a.uom_id.id,
"price_unit": self.product_a.standard_price,
"currency_id": self.foreign_currency.id,
}), Command.create({
"analytic_distribution": {account.id: analytic_distribution},
"product_id": self.product_a.id,
"quantity": 2,
"product_uom_id": self.product_a.uom_id.id,
"price_unit": self.product_a.standard_price,
"currency_id": self.foreign_currency.id,
})],
})
# Ensures that if no items have the main currency, the total is still displayed in the main currency.
# Expected total : product_price * 0.2 (rate) * 3 (number of products).
# Note : for some reason, the method to round the amount to the rounding of the currency is not 100% reliable.
# We use a float_compare in order to ensure the value is close enough to the expected result. This problem has no repercusion on the client side, since
# there is also a rounding method on this side to ensure the amount is correctly displayed.
items = project._get_profitability_items(with_action=False)['costs']
self.assertEqual('other_purchase_costs', items['data'][0]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'], items['data'][0]['sequence'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 0.6, items['data'][0]['to_bill'], 2), 0)
self.assertEqual(0.0, items['data'][0]['billed'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 0.6, items['total']['to_bill'], 2), 0)
self.assertEqual(0.0, items['total']['billed'])
# Create a bill 2 with the main currency.
bill_2 = self.env['account.move'].create({
"name": "Bill main currency",
"move_type": "in_invoice",
"state": "draft",
"partner_id": self.partner.id,
"invoice_date": datetime.today(),
"invoice_line_ids": [Command.create({
"analytic_distribution": {account.id: analytic_distribution},
"product_id": self.product_a.id,
"quantity": 1,
"product_uom_id": self.product_a.uom_id.id,
"price_unit": self.product_a.standard_price,
"currency_id": self.env.company.currency_id.id,
}), Command.create({
"analytic_distribution": {account.id: analytic_distribution},
"product_id": self.product_a.id,
"quantity": 2,
"product_uom_id": self.product_a.uom_id.id,
"price_unit": self.product_a.standard_price,
"currency_id": self.env.company.currency_id.id,
})],
})
# The 2 bills are in draft, therefore the "to_bill" section should contain the total cost of the 2 bills.
# The expected total is therefore product_price * 1 * 3 + product_price * 0.2 * 3 => * 3.6
items = project._get_profitability_items(with_action=False)['costs']
self.assertEqual('other_purchase_costs', items['data'][0]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'], items['data'][0]['sequence'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6, items['data'][0]['to_bill'], 2), 0)
self.assertEqual(0.0, items['data'][0]['billed'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6, items['total']['to_bill'], 2), 0)
self.assertEqual(0.0, items['total']['billed'])
# Bill 2 is posted. Its total is now in the 'billed' section, while the bill_1 is still in the 'to bill' one.
bill_2.action_post()
items = project._get_profitability_items(with_action=False)['costs']
self.assertEqual('other_purchase_costs', items['data'][0]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'], items['data'][0]['sequence'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 0.6, items['data'][0]['to_bill'], 2), 0)
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3, items['data'][0]['billed'], 2), 0)
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 0.6, items['total']['to_bill'], 2), 0)
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3, items['total']['billed'], 2), 0)
# Bill 1 is posted. Its total is now in the 'billed' section, the 'to bill' one should now be empty.
bill_1.action_post()
items = project._get_profitability_items(with_action=False)['costs']
self.assertEqual('other_purchase_costs', items['data'][0]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'], items['data'][0]['sequence'])
self.assertEqual(0.0, items['data'][0]['to_bill'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6, items['data'][0]['billed'], 2), 0)
self.assertEqual(0.0, items['total']['to_bill'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6, items['total']['billed'], 2), 0)
# create a new purchase order with the foreign company
purchase_order_foreign = self.env['purchase.order'].create({
"name": "A foreign purchase order",
"partner_id": self.partner_a.id,
"company_id": foreign_company.id,
"order_line": [Command.create({
"analytic_distribution": {account.id: analytic_distribution},
"product_id": self.product_order.id,
"product_qty": 1,
"price_unit": self.product_order.standard_price,
"currency_id": self.foreign_currency.id,
}), Command.create({
"analytic_distribution": {account.id: analytic_distribution},
"product_id": self.product_order.id,
"product_qty": 2,
"price_unit": self.product_order.standard_price,
"currency_id": self.foreign_currency.id,
})],
})
purchase_order_foreign.button_confirm()
self.assertEqual(purchase_order_foreign.invoice_status, 'to invoice')
# The section "purchase_order" should appear because the purchase order is validated, the total should be updated,
# but the "other_purchase_costs" shouldn't change, as we don't take into
# account bills from purchase orders in this section.
items = project._get_profitability_items(with_action=False)['costs']
self.assertEqual('purchase_order', items['data'][0]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['purchase_order'], items['data'][0]['sequence'])
self.assertEqual(0.0, items['data'][0]['billed'])
self.assertEqual(float_compare(-self.product_order.standard_price * analytic_contribution * 0.6, items['data'][0]['to_bill'], 2), 0)
self.assertEqual('other_purchase_costs', items['data'][1]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'], items['data'][1]['sequence'])
self.assertEqual(0.0, items['data'][1]['to_bill'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6, items['data'][1]['billed'], 2), 0)
self.assertEqual(float_compare(- self.product_order.standard_price * analytic_contribution * 0.6, items['total']['to_bill'], 2), 0)
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6, items['total']['billed'], 2), 0)
# create a new purchase order
purchase_order = self.env['purchase.order'].create({
"name": "A foreign purchase order",
"partner_id": self.partner_a.id,
"company_id": self.env.company.id,
"order_line": [Command.create({
"analytic_distribution": {account.id: analytic_distribution},
"product_id": self.product_order.id,
"product_qty": 1,
"price_unit": self.product_order.standard_price,
"currency_id": self.env.company.currency_id.id,
}), Command.create({
"analytic_distribution": {account.id: analytic_distribution},
"product_id": self.product_order.id,
"product_qty": 2,
"price_unit": self.product_order.standard_price,
"currency_id": self.env.company.currency_id.id,
})],
})
purchase_order.button_confirm()
self.assertEqual(purchase_order.invoice_status, 'to invoice')
# The section "purchase_order" should be updated with the new po values.
items = project._get_profitability_items(with_action=False)['costs']
self.assertEqual('purchase_order', items['data'][0]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['purchase_order'], items['data'][0]['sequence'])
self.assertEqual(0.0, items['data'][0]['billed'])
self.assertEqual(float_compare(-self.product_order.standard_price * analytic_contribution * 3.6, items['data'][0]['to_bill'], 2), 0)
self.assertEqual('other_purchase_costs', items['data'][1]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'], items['data'][1]['sequence'])
self.assertEqual(0.0, items['data'][1]['to_bill'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6, items['data'][1]['billed'], 2), 0)
self.assertEqual(float_compare(- self.product_order.standard_price * analytic_contribution * 3.6 , items['total']['to_bill'], 2), 0)
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6, items['total']['billed'], 2), 0)
self._create_invoice_for_po(purchase_order)
self.assertEqual(purchase_order.invoice_status, 'invoiced')
# The section "purchase_order" should now appear because purchase_order was invoiced.
# The purchase order of the main company has been billed. Its total should now be in the 'billed' section.
items = project._get_profitability_items(with_action=False)['costs']
self.assertEqual('purchase_order', items['data'][0]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['purchase_order'], items['data'][0]['sequence'])
self.assertEqual(float_compare(-self.product_order.standard_price * analytic_contribution * 0.6, items['data'][0]['to_bill'], 2), 0)
self.assertEqual(float_compare(-self.product_order.standard_price * analytic_contribution * 3, items['data'][0]['billed'], 2), 0)
self.assertEqual('other_purchase_costs', items['data'][1]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'], items['data'][1]['sequence'])
self.assertEqual(0.0, items['data'][1]['to_bill'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6, items['data'][1]['billed'], 2), 0)
self.assertEqual(float_compare(-self.product_order.standard_price * analytic_contribution * 0.6, items['total']['to_bill'], 2), 0)
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6 - self.product_order.standard_price * analytic_contribution * 3, items['total']['billed'], 2), 0)
self._create_invoice_for_po(purchase_order_foreign)
self.assertEqual(purchase_order_foreign.invoice_status, 'invoiced')
# The purchase order of the main company has been billed. Its total should now be in the 'billed' section.
# The 'to bill' section of the purchase order should now be empty
items = project._get_profitability_items(with_action=False)['costs']
self.assertEqual('purchase_order', items['data'][0]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['purchase_order'], items['data'][0]['sequence'])
self.assertEqual(0.0, items['data'][0]['to_bill'])
self.assertEqual(float_compare(-self.product_order.standard_price * analytic_contribution * 3.6, items['data'][0]['billed'], 2), 0)
self.assertEqual('other_purchase_costs', items['data'][1]['id'])
self.assertEqual(project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'], items['data'][1]['sequence'])
self.assertEqual(0.0, items['data'][1]['to_bill'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6, items['data'][1]['billed'], 2), 0)
self.assertEqual(0.0, items['total']['to_bill'])
self.assertEqual(float_compare(-self.product_a.standard_price * analytic_contribution * 3.6 - self.product_order.standard_price * analytic_contribution * 3.6, items['total']['billed'], 2), 0)
def test_project_purchase_order_smart_button(self):
project = self.env['project.project'].create({
'name': 'Test Project'
})
purchase_order = self.env['purchase.order'].create({
"name": "A purchase order",
"partner_id": self.partner_a.id,
"company_id": self.env.company.id,
"order_line": [Command.create({
"product_id": self.product_order.id,
"product_qty": 1,
"price_unit": self.product_order.standard_price,
"currency_id": self.foreign_currency.id,
})],
"project_id": project.id,
})
action = project.action_open_project_purchase_orders()
self.assertTrue(action)
self.assertEqual(action['res_id'], purchase_order.id)
def test_analytic_distribution_with_included_tax(self):
"""When calculating the profitability of a project, included taxes should not be calculated"""
included_tax = self.env['account.tax'].create({
@ -296,7 +617,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
'amount': '15.0',
'amount_type': 'percent',
'type_tax_use': 'purchase',
'price_include': True
'price_include_override': 'tax_included',
})
# create a purchase.order with the project account in analytic_distribution
@ -307,13 +628,13 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
'analytic_distribution': {self.analytic_account.id: 100},
'product_id': self.product_order.id,
'product_qty': 2, # plural value to check if the price is multiplied more than once
'taxes_id': [included_tax.id], # set the included tax
'tax_ids': [included_tax.id], # set the included tax
'price_unit': self.product_order.standard_price,
'currency_id': self.env.company.currency_id.id,
})],
})
purchase_order.button_confirm()
purchase_order.order_line.flush_recordset()
purchase_order.action_create_invoice()
# the profitability should not take taxes into account
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
@ -331,7 +652,6 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
},
)
purchase_order.action_create_invoice()
purchase_bill = purchase_order.invoice_ids # get the bill from the purchase
purchase_bill.invoice_date = datetime.today()
purchase_bill.action_post()
@ -368,8 +688,8 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
})
purchase_order.button_confirm()
# changing the uom to a higher number
purchase_order.order_line.product_uom = self.env.ref("uom.product_uom_dozen")
purchase_order.order_line.flush_recordset()
purchase_order.order_line.product_uom_id = self.env.ref("uom.product_uom_dozen")
purchase_order.action_create_invoice()
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
{
@ -386,6 +706,104 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
},
)
def test_cross_analytics_contribution(self):
cross_plan = self.env['account.analytic.plan'].create({'name': 'Cross Plan'})
cross_account = self.env['account.analytic.account'].create({
'name': "Cross Analytic Account",
'plan_id': cross_plan.id,
"company_id": self.env.company.id,
})
cross_distribution = 42
cross_order = self.env['purchase.order'].create({
'name': 'Cross Purchase Order',
"partner_id": self.partner_a.id,
"company_id": self.env.company.id,
'order_line': [
Command.create({
'analytic_distribution': {
f"{self.project.account_id.id},{cross_account.id}": cross_distribution,
},
"product_id": self.product_order.id,
"product_qty": 1,
"price_unit": self.product_order.standard_price,
"currency_id": self.env.company.currency_id.id,
}),
],
})
cross_order.button_confirm()
cross_order.action_create_invoice()
items = self.project._get_profitability_items(with_action=False)['costs']
self.assertEqual(
items['data'][0]['to_bill'],
-(self.product_order.standard_price * cross_distribution / 100)
)
def test_vendor_credit_note_profitability(self):
"""Reversing a vendor bill should cancel out the profitability costs."""
purchase_order = self.env['purchase.order'].create({
'name': "A Purchase",
'partner_id': self.partner_a.id,
'order_line': [Command.create({
'analytic_distribution': {self.analytic_account.id: 100},
'product_id': self.product_order.id,
})],
})
purchase_order.button_confirm()
vendor_bill = self._create_invoice_for_po(purchase_order)
items = self.project._get_profitability_items(with_action=False)['costs']
self.assertDictEqual(items['total'], {
'billed': -purchase_order.amount_untaxed,
'to_bill': 0.0,
})
credit_note = vendor_bill._reverse_moves()
items = self.project._get_profitability_items(with_action=False)['costs']
self.assertDictEqual(items['total'], {
'billed': -purchase_order.amount_untaxed,
'to_bill': purchase_order.amount_untaxed,
})
credit_note.invoice_date = vendor_bill.invoice_date
credit_note.action_post()
items = self.project._get_profitability_items(with_action=False)['costs']
self.assertDictEqual(items['total'], {
'billed': 0.0,
'to_bill': 0.0,
})
def test_project_purchase_profitability_without_analytic_distribution(self):
purchase_order = self.env['purchase.order'].create({
"name": "A purchase order",
"partner_id": self.partner_a.id,
"order_line": [Command.create({
'analytic_distribution': {self.analytic_account.id: 100},
'product_id': self.product_order.id,
})],
})
purchase_order.button_confirm()
vendor_bill = self._create_invoice_for_po(purchase_order)
vendor_bill.invoice_line_ids.analytic_distribution = False
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
{
'data': [{
'id': 'purchase_order',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['purchase_order'],
'to_bill': -235.0,
'billed': 0.0,
}],
'total': {
'to_bill': -235.0,
'billed': 0.0,
},
},
)
def test_profitability_foreign_currency_rate_on_bill_date(self):
"""Test that project profitability uses the correct currency rate (on bill date) for vendor bills in foreign currency."""
CurrencyRate = self.env['res.currency.rate']
@ -452,3 +870,164 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
float_compare(actual_billed, expected_cost, precision_digits=2) == 0,
f"Expected billed {expected_cost}, got {actual_billed}"
)
def test_project_purchase_profitability_with_split_bills(self):
"""
Test that project profitability is correctly computed when a purchase order
is billed in multiple steps (e.g. one partial bill followed by a final bill
covering the remaining quantity)
"""
purchase_order = self.env['purchase.order'].create({
'name': "A purchase order",
'partner_id': self.partner_a.id,
'company_id': self.env.company.id,
'order_line': [Command.create({
'product_id': self.product_order.id,
'price_unit': 100,
'tax_ids': [],
'product_qty': 5.0,
})],
'project_id': self.project.id,
})
purchase_order.button_confirm()
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
{
'data': [{
'id': 'purchase_order',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['purchase_order'],
'to_bill': -500.0,
'billed': 0.0,
}],
'total': {
'to_bill': -500.0,
'billed': 0.0,
},
},
)
purchase_order.action_create_invoice()
vendor_bill = purchase_order.invoice_ids
vendor_bill.invoice_date = datetime.today()
vendor_bill.invoice_line_ids.quantity = 2.0
vendor_bill.action_post()
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
{
'data': [{
'id': 'purchase_order',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['purchase_order'],
'to_bill': -300.0,
'billed': -200.0,
}],
'total': {
'to_bill': -300.0,
'billed': -200.0,
},
},
)
purchase_order.action_create_invoice()
vendor_bill_2 = purchase_order.invoice_ids[1]
vendor_bill_2.invoice_date = datetime.today()
vendor_bill_2.invoice_line_ids.quantity = 3.0
vendor_bill_2.invoice_line_ids.analytic_distribution = {}
vendor_bill_2.action_post()
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
{
'data': [{
'id': 'purchase_order',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['purchase_order'],
'to_bill': -300.0,
'billed': -200.0,
}],
'total': {
'to_bill': -300.0,
'billed': -200.0,
},
},
)
purchase_order.action_create_invoice()
vendor_bill_3 = purchase_order.invoice_ids[2]
vendor_bill_3.invoice_date = datetime.today()
vendor_bill_3.invoice_line_ids.quantity = 3.0
vendor_bill_3.action_post()
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
{
'data': [{
'id': 'purchase_order',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['purchase_order'],
'to_bill': 0.0,
'billed': -500.0,
}],
'total': {
'to_bill': 0.0,
'billed': -500.0,
},
},
)
def test_project_profitability_when_multiple_aa_in_the_same_line(self):
other_analytic_account = self.env['account.analytic.account'].create({
'name': 'Not important',
'code': 'KO-1234',
'plan_id': self.analytic_plan.id,
})
# create a new purchase order
purchase_order = self.env['purchase.order'].create({
"name": "A purchase order",
"partner_id": self.partner_a.id,
"order_line": [
Command.create({
"analytic_distribution": {
# this is the analytic_account that is linked to the project
f"{self.analytic_account.id},{other_analytic_account.id}": 100,
},
"product_id": self.product_order.id,
"product_qty": 1,
"price_unit": self.product_order.standard_price,
"currency_id": self.env.company.currency_id.id,
}),
Command.create({
"analytic_distribution": {
# this is the analytic_account that is linked to the project
f"{other_analytic_account.id},{self.analytic_account.id}": 100,
},
"product_id": self.product_order.id,
"product_qty": 1,
"price_unit": self.product_order.standard_price,
"currency_id": self.env.company.currency_id.id,
}),
],
})
purchase_order.button_confirm()
purchase_order.action_create_invoice()
vendor_bill = purchase_order.invoice_ids[0]
vendor_bill.invoice_date = datetime.today()
vendor_bill.action_post()
self.assertDictEqual(
self.project._get_profitability_items(False)['costs'],
{
'data': [{
'id': 'purchase_order',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['purchase_order'],
'to_bill': 0.0,
'billed': -470.0,
}],
'total': {
'to_bill': 0.0,
'billed': -470.0,
},
},
)