2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 12:35:46 +00:00

Client side currency conversion (#4293)

* Automatically update exchange rates when base currency is updated

* Adds API endpoint with currency exchange information

* Add unit testing for new endpoint

* Implement javascript code for client-side conversion

* Adds helper function for calculating total price of a dataset

* javascript cleanup

* Add functionality to sales order tables

* JS linting

* Update API version

* Prevent auto currency updates under certain conditions
This commit is contained in:
Oliver
2023-02-02 22:47:35 +11:00
committed by GitHub
parent 9a289948e5
commit eccd3be150
7 changed files with 344 additions and 116 deletions

View File

@ -4,9 +4,7 @@
blankImage,
deleteButton,
editButton,
formatCurrency,
formatDecimal,
formatPriceRange,
imageHoverIcon,
makeIconBadge,
makeIconButton,
@ -40,74 +38,6 @@ function deleteButton(url, text='{% trans "Delete" %}') {
}
/*
* format currency (money) value based on current settings
*
* Options:
* - currency: Currency code (uses default value if none provided)
* - locale: Locale specified (uses default value if none provided)
* - digits: Maximum number of significant digits (default = 10)
*/
function formatCurrency(value, options={}) {
if (value == null) {
return null;
}
var digits = options.digits || global_settings.PRICING_DECIMAL_PLACES || 6;
// Strip out any trailing zeros, etc
value = formatDecimal(value, digits);
// Extract default currency information
var currency = options.currency || global_settings.INVENTREE_DEFAULT_CURRENCY || 'USD';
// Exctract locale information
var locale = options.locale || navigator.language || 'en-US';
var formatter = new Intl.NumberFormat(
locale,
{
style: 'currency',
currency: currency,
maximumSignificantDigits: digits,
}
);
return formatter.format(value);
}
/*
* Format a range of prices
*/
function formatPriceRange(price_min, price_max, options={}) {
var p_min = price_min || price_max;
var p_max = price_max || price_min;
var quantity = options.quantity || 1;
if (p_min == null && p_max == null) {
return null;
}
p_min = parseFloat(p_min) * quantity;
p_max = parseFloat(p_max) * quantity;
var output = '';
output += formatCurrency(p_min, options);
if (p_min != p_max) {
output += ' - ';
output += formatCurrency(p_max, options);
}
return output;
}
/*
* Ensure a string does not exceed a maximum length.

View File

@ -2396,17 +2396,15 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
});
},
footerFormatter: function(data) {
var total = data.map(function(row) {
return +row['purchase_price']*row['quantity'];
}).reduce(function(sum, i) {
return sum + i;
}, 0);
var currency = (data.slice(-1)[0] && data.slice(-1)[0].purchase_price_currency) || 'USD';
return formatCurrency(total, {
currency: currency
});
return calculateTotalPrice(
data,
function(row) {
return row.purchase_price ? row.purchase_price * row.quantity : null;
},
function(row) {
return row.purchase_price_currency;
}
);
}
},
{
@ -2583,17 +2581,15 @@ function loadPurchaseOrderExtraLineTable(table, options={}) {
});
},
footerFormatter: function(data) {
var total = data.map(function(row) {
return +row['price'] * row['quantity'];
}).reduce(function(sum, i) {
return sum + i;
}, 0);
var currency = (data.slice(-1)[0] && data.slice(-1)[0].price_currency) || 'USD';
return formatCurrency(total, {
currency: currency,
});
return calculateTotalPrice(
data,
function(row) {
return row.price ? row.price * row.quantity : null;
},
function(row) {
return row.price_currency;
}
);
}
}
];
@ -3908,17 +3904,15 @@ function loadSalesOrderLineItemTable(table, options={}) {
});
},
footerFormatter: function(data) {
var total = data.map(function(row) {
return +row['sale_price'] * row['quantity'];
}).reduce(function(sum, i) {
return sum + i;
}, 0);
var currency = (data.slice(-1)[0] && data.slice(-1)[0].sale_price_currency) || 'USD';
return formatCurrency(total, {
currency: currency,
});
return calculateTotalPrice(
data,
function(row) {
return row.sale_price ? row.sale_price * row.quantity : null;
},
function(row) {
return row.sale_price_currency;
}
);
}
},
{
@ -4399,17 +4393,15 @@ function loadSalesOrderExtraLineTable(table, options={}) {
});
},
footerFormatter: function(data) {
var total = data.map(function(row) {
return +row['price'] * row['quantity'];
}).reduce(function(sum, i) {
return sum + i;
}, 0);
var currency = (data.slice(-1)[0] && data.slice(-1)[0].price_currency) || 'USD';
return formatCurrency(total, {
currency: currency,
});
return calculateTotalPrice(
data,
function(row) {
return row.price ? row.price * row.quantity : null;
},
function(row) {
return row.price_currency;
}
);
}
}
];

View File

@ -7,6 +7,11 @@
*/
/* exported
baseCurrency,
calculateTotalPrice,
convertCurrency,
formatCurrency,
formatPriceRange,
loadBomPricingChart,
loadPartSupplierPricingTable,
initPriceBreakSet,
@ -17,6 +22,242 @@
*/
/*
* Returns the base currency used for conversion operations
*/
function baseCurrency() {
return global_settings.INVENTREE_BASE_CURRENCY || 'USD';
}
/*
* format currency (money) value based on current settings
*
* Options:
* - currency: Currency code (uses default value if none provided)
* - locale: Locale specified (uses default value if none provided)
* - digits: Maximum number of significant digits (default = 10)
*/
function formatCurrency(value, options={}) {
if (value == null) {
return null;
}
var digits = options.digits || global_settings.PRICING_DECIMAL_PLACES || 6;
// Strip out any trailing zeros, etc
value = formatDecimal(value, digits);
// Extract default currency information
var currency = options.currency || global_settings.INVENTREE_DEFAULT_CURRENCY || 'USD';
// Exctract locale information
var locale = options.locale || navigator.language || 'en-US';
var formatter = new Intl.NumberFormat(
locale,
{
style: 'currency',
currency: currency,
maximumSignificantDigits: digits,
}
);
return formatter.format(value);
}
/*
* Format a range of prices
*/
function formatPriceRange(price_min, price_max, options={}) {
var p_min = price_min || price_max;
var p_max = price_max || price_min;
var quantity = options.quantity || 1;
if (p_min == null && p_max == null) {
return null;
}
p_min = parseFloat(p_min) * quantity;
p_max = parseFloat(p_max) * quantity;
var output = '';
output += formatCurrency(p_min, options);
if (p_min != p_max) {
output += ' - ';
output += formatCurrency(p_max, options);
}
return output;
}
// TODO: Implement a better version of caching here
var cached_exchange_rates = null;
/*
* Retrieve currency conversion rate information from the server
*/
function getCurrencyConversionRates() {
if (cached_exchange_rates != null) {
return cached_exchange_rates;
}
inventreeGet('{% url "api-currency-exchange" %}', {}, {
async: false,
success: function(response) {
cached_exchange_rates = response;
}
});
return cached_exchange_rates;
}
/*
* Calculate the total price for a given dataset.
* Within each 'row' in the dataset, the 'price' attribute is denoted by 'key' variable
*
* The target currency is determined as follows:
* 1. Provided as options.currency
* 2. All rows use the same currency (defaults to this)
* 3. Use the result of baseCurrency function
*/
function calculateTotalPrice(dataset, value_func, currency_func, options={}) {
var currency = options.currency;
var rates = getCurrencyConversionRates();
if (!rates) {
console.error('Could not retrieve currency conversion information from the server');
return `<span class='icon-red fas fa-exclamation-circle' title='{% trans "Error fetching currency data" %}'></span>`;
}
if (!currency) {
// Try to determine currency from the dataset
var common_currency = true;
for (var idx = 0; idx < dataset.length; idx++) {
var row = dataset[idx];
var row_currency = currency_func(row);
if (row_currency == null) {
continue;
}
if (currency == null) {
currency = row_currency;
}
if (currency != row_currency) {
common_currency = false;
break;
}
}
// Inconsistent currencies between rows - revert to base currency
if (!common_currency) {
currency = baseCurrency();
}
}
var total = null;
for (var ii = 0; ii < dataset.length; ii++) {
var row = dataset[ii];
// Pass the row back to the decoder
var value = value_func(row);
// Ignore null values
if (value == null) {
continue;
}
// Convert to the desired currency
value = convertCurrency(
value,
currency_func(row) || baseCurrency(),
currency,
rates
);
if (value == null) {
continue;
}
// Total is null until we get a good value
if (total == null) {
total = 0;
}
total += value;
}
return formatCurrency(total, {
currency: currency,
});
}
/*
* Convert from one specified currency into another
*
* @param {number} value - numerical value
* @param {string} source_currency - The source currency code e.g. 'AUD'
* @param {string} target_currency - The target currency code e.g. 'USD'
* @param {object} rate_data - Currency exchange rate data received from the server
*/
function convertCurrency(value, source_currency, target_currency, rate_data) {
if (value == null) {
console.warn('Null value passed to convertCurrency function');
return null;
}
// Short circuit the case where the currencies are the same
if (source_currency == target_currency) {
return value;
}
if (!('base_currency' in rate_data)) {
console.error('Currency data missing base_currency parameter');
return null;
}
if (!('exchange_rates' in rate_data)) {
console.error('Currency data missing exchange_rates parameter');
return null;
}
var rates = rate_data['exchange_rates'];
if (!(source_currency in rates)) {
console.error(`Source currency '${source_currency}' not found in exchange rate data`);
return null;
}
if (!(target_currency in rates)) {
console.error(`Target currency '${target_currency}' not found in exchange rate date`);
return null;
}
// We assume that the 'base exchange rate' is 1:1
return value / rates[source_currency] * rates[target_currency];
}
/*
* Load BOM pricing chart
*/