[FIX] project_purchase: use price_subtotal in project profitability
Steps to reproduce the issue: 1. Create a purchase Tax and tick Include in Price in the Advanced Options 2. Create a Purchase Order with Analytic Distribution towards a Project in the Order Line 3. Set the purchase Tax created before as the tax of the Order Line 4. Confirm the Purchase Order 5. Make sure the Project is Billable, then go to the Project Updates 6. The profitability calculated the price with the included Tax 7. Create a Vendor Bill and set the same Tax and Analytic Distribution as the Purchase order 8. Confirm the Bill 9. Return to the Project Updates 10. The profitability doesn't calculate the included Tax Explanation: In `project.project._get_profitability_items`, we can find an inconsistency in the queries. The query for `purchase.order.line` is looking for `price_unit`, which takes included taxes into account. https://github.com/odoo/odoo/blob/249aaac7bd1a13d62c947cddb1835772659aabff/addons/project_purchase/models/project.py#L125-L132 The query for `account.move.line` retrieves `price_subtotal`, which does not. https://github.com/odoo/odoo/blob/249aaac7bd1a13d62c947cddb1835772659aabff/addons/project_purchase/models/project.py#L171-L181 Suggested fix: In `project.project._get_revenues_items_from_invoices`, the `account.move.line` query retrieves `price_subtotal` as well. https://github.com/odoo/odoo/blob/8750b94c53c6ab58567873b0745fa6d9a18c97d0/addons/sale_project/models/project.py#L467-L474 With above information and input of PO (olma), taxes will not be calculated in `project.project._get_profitability_items`, therefore we will replace `price_unit` with `price_subtotal` in the `purchase.order.line` query. opw-3781426 closes odoo/odoo#163567 X-original-commit: d4fa9ff7b1e2e8b5cd466d0f5433f99a1b407ac9 Signed-off-by: Xavier Bol (xbo) <xbo@odoo.com>
This commit is contained in:
@@ -143,7 +143,7 @@ class Project(models.Model):
|
|||||||
('state', 'in', ['purchase', 'done']),
|
('state', 'in', ['purchase', 'done']),
|
||||||
'|',
|
'|',
|
||||||
('qty_invoiced', '>', 0),
|
('qty_invoiced', '>', 0),
|
||||||
'|', ('qty_to_invoice', '>', 0), ('product_uom_qty', '>', 0),
|
'|', ('qty_to_invoice', '>', 0), ('product_qty', '>', 0),
|
||||||
], order=self.env['purchase.order.line']._order)
|
], order=self.env['purchase.order.line']._order)
|
||||||
query.add_where(
|
query.add_where(
|
||||||
SQL(
|
SQL(
|
||||||
@@ -152,7 +152,7 @@ class Project(models.Model):
|
|||||||
self.env['purchase.order.line']._query_analytic_accounts(),
|
self.env['purchase.order.line']._query_analytic_accounts(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
query_string, query_param = query.select('"purchase_order_line".id', 'qty_invoiced', 'qty_to_invoice', 'product_uom_qty', 'price_unit', 'purchase_order_line.currency_id', '"purchase_order_line".analytic_distribution')
|
query_string, query_param = query.select('"purchase_order_line".id', 'qty_invoiced', 'qty_to_invoice', 'product_qty', 'price_subtotal', 'purchase_order_line.currency_id', '"purchase_order_line".analytic_distribution')
|
||||||
self._cr.execute(query_string, query_param)
|
self._cr.execute(query_string, query_param)
|
||||||
purchase_order_line_read = [{
|
purchase_order_line_read = [{
|
||||||
**pol,
|
**pol,
|
||||||
@@ -168,17 +168,18 @@ class Project(models.Model):
|
|||||||
for pol_read in purchase_order_line_read:
|
for pol_read in purchase_order_line_read:
|
||||||
purchase_order_line_invoice_line_ids.extend(pol_read['invoice_lines'].ids)
|
purchase_order_line_invoice_line_ids.extend(pol_read['invoice_lines'].ids)
|
||||||
currency = self.env['res.currency'].browse(pol_read['currency_id']).with_prefetch(currency_ids)
|
currency = self.env['res.currency'].browse(pol_read['currency_id']).with_prefetch(currency_ids)
|
||||||
price_unit = currency._convert(pol_read['price_unit'], self.currency_id, self.company_id)
|
price_subtotal = currency._convert(pol_read['price_subtotal'], self.currency_id, self.company_id)
|
||||||
|
price_subtotal_unit = price_subtotal / pol_read['product_qty'] if pol_read['product_qty'] else 0.0
|
||||||
# an analytic account can appear several time in an analytic distribution with different repartition percentage
|
# an analytic account can appear several time in an analytic distribution with different repartition percentage
|
||||||
analytic_contribution = sum(
|
analytic_contribution = sum(
|
||||||
percentage for ids, percentage in pol_read['analytic_distribution'].items()
|
percentage for ids, percentage in pol_read['analytic_distribution'].items()
|
||||||
if str(self.analytic_account_id.id) in ids.split(',')
|
if str(self.analytic_account_id.id) in ids.split(',')
|
||||||
) / 100.
|
) / 100.
|
||||||
amount_invoiced -= price_unit * pol_read['qty_invoiced'] * analytic_contribution if pol_read['qty_invoiced'] > 0 else 0.0
|
amount_invoiced -= price_subtotal_unit * pol_read['qty_invoiced'] * analytic_contribution if pol_read['qty_invoiced'] > 0 else 0.0
|
||||||
if pol_read['qty_to_invoice'] > 0:
|
if pol_read['qty_to_invoice'] > 0:
|
||||||
amount_to_invoice -= price_unit * pol_read['qty_to_invoice'] * analytic_contribution
|
amount_to_invoice -= price_subtotal_unit * pol_read['qty_to_invoice'] * analytic_contribution
|
||||||
else:
|
else:
|
||||||
amount_to_invoice -= price_unit * (pol_read['product_uom_qty'] - pol_read['qty_invoiced']) * analytic_contribution
|
amount_to_invoice -= price_subtotal_unit * (pol_read['product_qty'] - pol_read['qty_invoiced']) * analytic_contribution
|
||||||
purchase_order_line_ids.append(pol_read['id'])
|
purchase_order_line_ids.append(pol_read['id'])
|
||||||
costs = profitability_items['costs']
|
costs = profitability_items['costs']
|
||||||
section_id = 'purchase_order'
|
section_id = 'purchase_order'
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
|
|||||||
})],
|
})],
|
||||||
})
|
})
|
||||||
purchase_order.button_confirm()
|
purchase_order.button_confirm()
|
||||||
|
purchase_order.order_line.flush_recordset()
|
||||||
# we should have a new section "purchase_order", the total should be updated,
|
# 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 take into
|
# but 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
|
# account bills from purchase orders, as those are already taken into calculations
|
||||||
@@ -267,6 +268,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
|
|||||||
})],
|
})],
|
||||||
})
|
})
|
||||||
purchase_order.button_confirm()
|
purchase_order.button_confirm()
|
||||||
|
purchase_order.order_line.flush_recordset()
|
||||||
self.assertDictEqual(
|
self.assertDictEqual(
|
||||||
self.project._get_profitability_items(False)['costs'],
|
self.project._get_profitability_items(False)['costs'],
|
||||||
{
|
{
|
||||||
@@ -424,6 +426,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
|
|||||||
})],
|
})],
|
||||||
})
|
})
|
||||||
purchase_order_foreign.button_confirm()
|
purchase_order_foreign.button_confirm()
|
||||||
|
purchase_order_foreign.order_line.flush_recordset()
|
||||||
|
|
||||||
# We should have a new section "purchase_order", the total should be updated,
|
# 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 take into
|
# but the "other_purchase_costs" shouldn't change, as we don't take into
|
||||||
@@ -460,6 +463,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
|
|||||||
})],
|
})],
|
||||||
})
|
})
|
||||||
purchase_order.button_confirm()
|
purchase_order.button_confirm()
|
||||||
|
purchase_order.order_line.flush_recordset()
|
||||||
# The 'to bill' section should be updated in the 'total' and 'purchase orders' sections.
|
# The 'to bill' section should be updated in the 'total' and 'purchase orders' sections.
|
||||||
items = project._get_profitability_items(with_action=False)['costs']
|
items = project._get_profitability_items(with_action=False)['costs']
|
||||||
self.assertEqual('purchase_order', items['data'][0]['id'])
|
self.assertEqual('purchase_order', items['data'][0]['id'])
|
||||||
@@ -508,6 +512,103 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
|
|||||||
purchase_bill.invoice_date = datetime.today()
|
purchase_bill.invoice_date = datetime.today()
|
||||||
purchase_bill.action_post()
|
purchase_bill.action_post()
|
||||||
|
|
||||||
|
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({
|
||||||
|
'name': 'included tax',
|
||||||
|
'amount': '15.0',
|
||||||
|
'amount_type': 'percent',
|
||||||
|
'type_tax_use': 'purchase',
|
||||||
|
'price_include': True
|
||||||
|
})
|
||||||
|
|
||||||
|
# create a purchase.order with the project account in analytic_distribution
|
||||||
|
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,
|
||||||
|
'product_qty': 2, # plural value to check if the price is multiplied more than once
|
||||||
|
'taxes_id': [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()
|
||||||
|
# the profitability should not take taxes into account
|
||||||
|
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': -(purchase_order.amount_untaxed),
|
||||||
|
'billed': 0.0,
|
||||||
|
}],
|
||||||
|
'total': {
|
||||||
|
'to_bill': -(purchase_order.amount_untaxed),
|
||||||
|
'billed': 0.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
# same here, taxes should not be calculated in the profitability
|
||||||
|
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': -(purchase_bill.amount_untaxed),
|
||||||
|
}],
|
||||||
|
'total': {
|
||||||
|
'to_bill': 0.0,
|
||||||
|
'billed': -(purchase_bill.amount_untaxed),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_analytic_distribution_with_mismatched_uom(self):
|
||||||
|
"""When changing the unit of measure, the profitability should still match the price_subtotal of the order line"""
|
||||||
|
# create a purchase.order with the project account in analytic_distribution
|
||||||
|
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,
|
||||||
|
'product_qty': 1,
|
||||||
|
'price_unit': self.product_order.standard_price,
|
||||||
|
'currency_id': self.env.company.currency_id.id,
|
||||||
|
})],
|
||||||
|
})
|
||||||
|
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()
|
||||||
|
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': -(purchase_order.amount_untaxed),
|
||||||
|
'billed': 0.0,
|
||||||
|
}],
|
||||||
|
'total': {
|
||||||
|
'to_bill': -(purchase_order.amount_untaxed),
|
||||||
|
'billed': 0.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def test_cross_analytics_contribution(self):
|
def test_cross_analytics_contribution(self):
|
||||||
cross_plan = self.env['account.analytic.plan'].create({'name': 'Cross Plan'})
|
cross_plan = self.env['account.analytic.plan'].create({'name': 'Cross Plan'})
|
||||||
cross_account = self.env['account.analytic.account'].create({
|
cross_account = self.env['account.analytic.account'].create({
|
||||||
@@ -535,8 +636,9 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
|
|||||||
})
|
})
|
||||||
|
|
||||||
cross_order.button_confirm()
|
cross_order.button_confirm()
|
||||||
items = self.project._get_profitability_items()
|
cross_order.order_line.flush_recordset()
|
||||||
|
items = self.project._get_profitability_items(with_action=False)['costs']
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
items['costs']['data'][0]['to_bill'],
|
items['data'][0]['to_bill'],
|
||||||
self.product_order.standard_price *-cross_distribution/100
|
-(self.product_order.standard_price * cross_distribution / 100)
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user