[FIX] sale: currency conversions and rates
Before this commit the reports in sales and associated app suffered from a confusion between multiplicative and divisive rates for currency conversion. After this commit this will no longer be the case. There are several models with their own currencies that are involved in computing the amount of a sale order: - the order's company currency `order.company_id.currency_id` - the order's pricelist currency `order.pricelist_id.currency_id` - the product's currency for each product in the sale order `product.currency_id` In the case of a report we need also to take into account the current user's company currency: `self.env.company.currency_id`. The `sale_order.currency_rate` is the **multiplicative <u>rate</u>** to convert from the company currency into the SO currency. Any product added to a sale order follow the next conversion. Let `conversion_rate` be the conversion rate between the product's currency and the order's company currency, then: ```python product_amount_converted = product.list_price / conversion_rate order.amount_total += product_amount_converted * order.currency_rate ``` The rates returned by `_get_query_currency_table()`, on the other hand, are computed as the **multiplicative <u>ratio</u>** to convert from each company currency into the current user's company currency. Any order that is to be analysed on a *report* follow the next conversion. Let `current_company_currency_rate` be the conversion rate between the order's company currency and the current user's company, then: ```python order_amount_in_cmp_currency = order.amount_total / order.currency_rate report.order_id.price_total = order_amount_in_cmp_currency * current_company_currency_rate ``` What follow is a numerical example. Suppose you have the following multi-company and multi-currency environment. |order.id|order.company |line.subtotal|order.currency|order.currency_rate| | ------ | ----------- | ----------- | ------------ | ----------------- | |1 |BE Company (EUR)|1000 |INR |89.46 | |2 |BE Company (EUR)|2000 |USD |1.09 | |3 |BE Company (EUR)|3000 |EUR |1.0 | |4 |US Company (USD)|4000 |USD |1.0 | If we try to convert all of this for reporting in the US company, the currency table returned by `_get_query_currency_table()` will give this (company currencies vs USD): |currency_table.company|currency_table.rate| | -------------------- | ------------------ | |BE Company (EUR) |1.09 | |US Company (USD) |1.0 | Finally, in order to convert each amount into USD, applying the formulae listed above should give the following result: |order.id|order.company|display_price_usd | | ------ | ----------- | -------------------------- | |1 |BE Company |1000 / 89.46 * 1.09 = 12.18 | |2 |BE Company |2000 / 1.09 * 1.09 = 2000.00| |3 |BE Company |3000 / 1.0 * 1.09 = 3270.00 | |4 |US Company |4000 / 1.0 * 1.0 = 4000.00 | |Total (USD): 9282.18| Task - 3277006 closes odoo/odoo#125402 X-original-commit: e8f7a692eaf6b87ccf4a8d95b7a46146b1199bf2 Signed-off-by: Victor Feyens (vfe) <vfe@odoo.com> Signed-off-by: Horacio Tellez Perez (hote) <hote@odoo.com>
This commit is contained in:
committed by
Horacio Tellez (hote)
parent
07a5d9b1ec
commit
8447c93ba2
@@ -34,19 +34,19 @@ class SaleReport(models.Model):
|
||||
CASE WHEN pos.state != 'invoiced' THEN SUM(l.qty) ELSE 0 END AS qty_to_invoice,
|
||||
SUM(l.price_subtotal_incl)
|
||||
/ MIN({self._case_value_or_one('pos.currency_rate')})
|
||||
/ {self._case_value_or_one('currency_table.rate')}
|
||||
* {self._case_value_or_one('currency_table.rate')}
|
||||
AS price_total,
|
||||
SUM(l.price_subtotal)
|
||||
/ MIN({self._case_value_or_one('pos.currency_rate')})
|
||||
/ {self._case_value_or_one('currency_table.rate')}
|
||||
* {self._case_value_or_one('currency_table.rate')}
|
||||
AS price_subtotal,
|
||||
(CASE WHEN pos.state != 'invoiced' THEN SUM(l.price_subtotal) ELSE 0 END)
|
||||
/ MIN({self._case_value_or_one('pos.currency_rate')})
|
||||
/ {self._case_value_or_one('currency_table.rate')}
|
||||
* {self._case_value_or_one('currency_table.rate')}
|
||||
AS amount_to_invoice,
|
||||
(CASE WHEN pos.state = 'invoiced' THEN SUM(l.price_subtotal) ELSE 0 END)
|
||||
/ MIN({self._case_value_or_one('pos.currency_rate')})
|
||||
/ {self._case_value_or_one('currency_table.rate')}
|
||||
* {self._case_value_or_one('currency_table.rate')}
|
||||
AS amount_invoiced,
|
||||
count(*) AS nbr,
|
||||
pos.name AS name,
|
||||
@@ -74,7 +74,7 @@ class SaleReport(models.Model):
|
||||
l.discount AS discount,
|
||||
SUM((l.price_unit * l.discount * l.qty / 100.0
|
||||
/ {self._case_value_or_one('pos.currency_rate')}
|
||||
/ {self._case_value_or_one('currency_table.rate')}))
|
||||
* {self._case_value_or_one('currency_table.rate')}))
|
||||
AS discount_amount,
|
||||
concat('pos.order', ',', pos.id) AS order_reference"""
|
||||
|
||||
|
||||
@@ -104,22 +104,22 @@ class SaleReport(models.Model):
|
||||
CASE WHEN l.product_id IS NOT NULL THEN SUM(l.qty_to_invoice / u.factor * u2.factor) ELSE 0 END AS qty_to_invoice,
|
||||
CASE WHEN l.product_id IS NOT NULL THEN SUM(l.price_total
|
||||
/ {self._case_value_or_one('s.currency_rate')}
|
||||
/ {self._case_value_or_one('currency_table.rate')}
|
||||
* {self._case_value_or_one('currency_table.rate')}
|
||||
) ELSE 0
|
||||
END AS price_total,
|
||||
CASE WHEN l.product_id IS NOT NULL THEN SUM(l.price_subtotal
|
||||
/ {self._case_value_or_one('s.currency_rate')}
|
||||
/ {self._case_value_or_one('currency_table.rate')}
|
||||
* {self._case_value_or_one('currency_table.rate')}
|
||||
) ELSE 0
|
||||
END AS price_subtotal,
|
||||
CASE WHEN l.product_id IS NOT NULL THEN SUM(l.untaxed_amount_to_invoice
|
||||
/ {self._case_value_or_one('s.currency_rate')}
|
||||
/ {self._case_value_or_one('currency_table.rate')}
|
||||
* {self._case_value_or_one('currency_table.rate')}
|
||||
) ELSE 0
|
||||
END AS untaxed_amount_to_invoice,
|
||||
CASE WHEN l.product_id IS NOT NULL THEN SUM(l.untaxed_amount_invoiced
|
||||
/ {self._case_value_or_one('s.currency_rate')}
|
||||
/ {self._case_value_or_one('currency_table.rate')}
|
||||
* {self._case_value_or_one('currency_table.rate')}
|
||||
) ELSE 0
|
||||
END AS untaxed_amount_invoiced,
|
||||
COUNT(*) AS nbr,
|
||||
@@ -148,7 +148,7 @@ class SaleReport(models.Model):
|
||||
l.discount AS discount,
|
||||
CASE WHEN l.product_id IS NOT NULL THEN SUM(l.price_unit * l.product_uom_qty * l.discount / 100.0
|
||||
/ {self._case_value_or_one('s.currency_rate')}
|
||||
/ {self._case_value_or_one('currency_table.rate')}
|
||||
* {self._case_value_or_one('currency_table.rate')}
|
||||
) ELSE 0
|
||||
END AS discount_amount,
|
||||
concat('sale.order', ',', s.id) AS order_reference"""
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from freezegun import freeze_time
|
||||
|
||||
from odoo import fields
|
||||
from odoo.fields import Command
|
||||
from odoo.tests import tagged
|
||||
@@ -17,66 +15,113 @@ class TestSaleReportCurrencyRate(SaleCommon):
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
cls.company = cls.env['res.company'].create({
|
||||
'name': 'Test Company',
|
||||
'currency_id': cls.env.ref('base.USD').id,
|
||||
cls.usd_cmp = cls.env['res.company'].create({
|
||||
'name': 'USD Company', 'currency_id': cls.env.ref('base.USD').id,
|
||||
})
|
||||
cls.eur_cmp = cls.env['res.company'].create({
|
||||
'name': 'EUR Company', 'currency_id': cls.env.ref('base.EUR').id,
|
||||
})
|
||||
|
||||
def test_sale_report_foreign_currency(self):
|
||||
# Test the amounts shown in the sales report for orders in foreign currency
|
||||
currency_eur = self._enable_currency('EUR')
|
||||
"""
|
||||
Test that amounts are correctly converted between currencies.
|
||||
There are two different conversions to take into account:
|
||||
- currency of the sale order pricelist -> currency of the sale order company
|
||||
- currency of sale order company -> currency of the current user company
|
||||
Adjustment between past and present rates must also be taken into account.
|
||||
"""
|
||||
|
||||
eur_pricelist = self.env['product.pricelist'].create({
|
||||
'name': 'Pricelist (EUR)',
|
||||
'currency_id': currency_eur.id,
|
||||
})
|
||||
companies = self.usd_cmp + self.eur_cmp
|
||||
today = fields.Date.today()
|
||||
past_day = fields.Date.to_date('2020-01-01')
|
||||
usd = self.usd_cmp.currency_id
|
||||
eur = self.eur_cmp.currency_id
|
||||
ars = self._enable_currency('ARS')
|
||||
|
||||
with freeze_time('2022-02-22'):
|
||||
self.env['res.currency.rate'].create({
|
||||
'name': fields.Date.today(),
|
||||
'company_rate': 3.0,
|
||||
'currency_id': currency_eur.id,
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
eur_order = self.env['sale.order'].with_company(self.company).create({
|
||||
'partner_id': self.partner.id,
|
||||
'pricelist_id': eur_pricelist.id,
|
||||
'date_order': fields.Date.today(),
|
||||
'order_line': [
|
||||
Command.create({
|
||||
'product_id': self.product.id,
|
||||
'product_uom_qty': 1.0,
|
||||
}),
|
||||
]
|
||||
})
|
||||
# the sales order's amount is in foreign currency
|
||||
self.assertEqual(eur_order.amount_total, 60.0)
|
||||
# Create corresponding pricelists and rates.
|
||||
pricelists = self.env['product.pricelist'].create([
|
||||
{'name': 'Pricelist (USD)', 'currency_id': usd.id},
|
||||
{'name': 'Pricelist (EUR)', 'currency_id': eur.id},
|
||||
{'name': 'Pricelist (ARS)', 'currency_id': ars.id},
|
||||
])
|
||||
self.env['res.currency.rate'].create([
|
||||
{'name': past_day, 'rate': 555, 'currency_id': ars.id, 'company_id': self.eur_cmp.id},
|
||||
{'name': past_day, 'rate': 1.0, 'currency_id': eur.id, 'company_id': self.eur_cmp.id},
|
||||
{'name': past_day, 'rate': 999, 'currency_id': usd.id, 'company_id': self.eur_cmp.id},
|
||||
{'name': past_day, 'rate': 3.0, 'currency_id': ars.id, 'company_id': self.usd_cmp.id},
|
||||
{'name': past_day, 'rate': 0.1, 'currency_id': eur.id, 'company_id': self.usd_cmp.id},
|
||||
{'name': past_day, 'rate': 1.0, 'currency_id': usd.id, 'company_id': self.usd_cmp.id},
|
||||
{'name': today, 'rate': 222, 'currency_id': ars.id, 'company_id': self.eur_cmp.id},
|
||||
{'name': today, 'rate': 1.0, 'currency_id': eur.id, 'company_id': self.eur_cmp.id},
|
||||
{'name': today, 'rate': 2.9, 'currency_id': usd.id, 'company_id': self.eur_cmp.id},
|
||||
{'name': today, 'rate': 101, 'currency_id': ars.id, 'company_id': self.usd_cmp.id},
|
||||
{'name': today, 'rate': 0.6, 'currency_id': eur.id, 'company_id': self.usd_cmp.id},
|
||||
{'name': today, 'rate': 1.0, 'currency_id': usd.id, 'company_id': self.usd_cmp.id},
|
||||
])
|
||||
|
||||
# the report should show the amount in company currency
|
||||
report_line = self.env['sale.report'].sudo().search(
|
||||
[('order_reference', '=', f"sale.order,{eur_order.id}")])
|
||||
self.assertEqual(report_line.price_total, 20.0)
|
||||
self.assertEqual(self.product.currency_id, usd)
|
||||
|
||||
def test_sale_report_company_currency(self):
|
||||
usd_pricelist = self.env['product.pricelist'].create({
|
||||
'name': 'Pricelist (USD)',
|
||||
'currency_id': self.company.currency_id.id,
|
||||
})
|
||||
# Needed to get conversion rates between companies.
|
||||
currency_rates = (companies + self.env.company).mapped('currency_id')._get_rates(
|
||||
self.env.company, today
|
||||
)
|
||||
|
||||
usd_order = self.env['sale.order'].with_company(self.company).create({
|
||||
'partner_id': self.partner.id,
|
||||
'pricelist_id': usd_pricelist.id,
|
||||
'order_line': [
|
||||
Command.create({
|
||||
'product_id': self.product.id,
|
||||
'product_uom_qty': 1.0,
|
||||
}),
|
||||
]
|
||||
})
|
||||
# the sales order's amount is in company currency
|
||||
self.assertEqual(usd_order.amount_total, 20.0)
|
||||
sale_orders = self.env['sale.order']
|
||||
expected_reported_amount = 0 # The total amount of all sale orders in the report.
|
||||
qty = 0 # to add variety to the data
|
||||
|
||||
# the report should match the amount on the SO
|
||||
report_line = self.env['sale.report'].sudo().search(
|
||||
[('order_reference', '=', f"sale.order,{usd_order.id}")])
|
||||
self.assertEqual(report_line.price_total, 20.0)
|
||||
# Create sale orders
|
||||
for company in companies:
|
||||
SaleOrder = self.env['sale.order'].with_company(company)
|
||||
for date in (past_day, today):
|
||||
for pricelist in pricelists:
|
||||
qty += 1
|
||||
order = SaleOrder.create({
|
||||
'partner_id': self.partner.id,
|
||||
'pricelist_id': pricelist.id,
|
||||
'date_order': date,
|
||||
'order_line': [Command.create(
|
||||
{'product_id': self.product.id, 'product_uom_qty': qty}
|
||||
)],
|
||||
})
|
||||
sale_orders |= order
|
||||
|
||||
expected_so_currency_rate = self.env['res.currency.rate'].search([
|
||||
('name', '=', date),
|
||||
('currency_id', '=', pricelist.currency_id.id),
|
||||
('company_id', '=', company.id),
|
||||
]).rate
|
||||
expected_product_currency_rate = self.env['res.currency.rate'].search([
|
||||
('name', '=', date),
|
||||
('currency_id', '=', self.product.currency_id.id),
|
||||
('company_id', '=', company.id),
|
||||
]).rate
|
||||
|
||||
# To find the total amount we convert the price of the product from its currency
|
||||
# to the currency of the so company and then from it to the currency of the so
|
||||
# pricelist.
|
||||
price_for_so_company = self.product.list_price / expected_product_currency_rate
|
||||
expected_rounded_price = pricelist.currency_id.round(
|
||||
price_for_so_company * expected_so_currency_rate
|
||||
)
|
||||
|
||||
expected_amount_total = qty * expected_rounded_price
|
||||
self.assertAlmostEqual(order.currency_rate, expected_so_currency_rate)
|
||||
self.assertAlmostEqual(order.amount_total, expected_amount_total)
|
||||
|
||||
# The amount in the report is converted first to the currency of the company and
|
||||
# then to the currency of the current company (self.env.company).
|
||||
current_company_rate = currency_rates[self.env.company.currency_id.id]
|
||||
so_company_rate = currency_rates[company.currency_id.id]
|
||||
conversion_rate = (current_company_rate / so_company_rate)
|
||||
expected_reported_amount += (
|
||||
order.amount_total / order.currency_rate * conversion_rate
|
||||
)
|
||||
|
||||
# The report should show the amount in the current (in this case usd) company currency.
|
||||
report_lines = self.env['sale.report'].sudo().with_context(
|
||||
allow_company_ids=[self.usd_cmp.id, self.eur_cmp.id]
|
||||
).search([('order_reference', 'in', [f'sale.order,{so_id}' for so_id in sale_orders.ids])])
|
||||
|
||||
price_total = sum(report_lines.mapped('price_total'))
|
||||
self.assertAlmostEqual(price_total, expected_reported_amount)
|
||||
|
||||
@@ -13,6 +13,6 @@ class SaleReport(models.Model):
|
||||
res = super()._select_additional_fields()
|
||||
res['margin'] = f"""SUM(l.margin
|
||||
/ {self._case_value_or_one('s.currency_rate')}
|
||||
/ {self._case_value_or_one('currency_table.rate')})
|
||||
* {self._case_value_or_one('currency_table.rate')})
|
||||
"""
|
||||
return res
|
||||
|
||||
Reference in New Issue
Block a user