The solution is to be used on MS-SQL server. The presented code was tested on MS-SQL 2012.
I was given the exchange_rate
table that reflects the human interface (for INSERT and UPDATE). Because of that, the exchange rate is expressed as two coefficient. Here is the table definition with the initial content:
IF (OBJECT_ID(N'dbo.exchange_rates', 'U') IS NULL) BEGIN
CREATE TABLE dbo.exchange_rates (
ID int IDENTITY PRIMARY KEY NOT NULL,
date_from date NOT NULL,
cur1 char(3) NOT NULL, -- ISO 4217 (e.g. 'JPY')
coef1 numeric(16, 9) NOT NULL, -- coefficient for cur1 (e.g. 100)
cur2 char(3) NOT NULL, -- e.g. 'CZK'
coef2 numeric(16, 9) NOT NULL -- e.g. 21.9 then means 100 JPY = 21.9 CZK
)
-- Initial rows.
INSERT INTO dbo.exchange_rates
(date_from, coef1, cur1, coef2, cur2) VALUES
('2017-01-01', 27.020, 'CZK', 1, 'EUR'),
('2017-07-03', 1, 'EUR', 26.140, 'CZK'),
('2017-09-05', 1, 'EUR', 26.075, 'CZK'),
('2017-09-20', 1, 'EUR', 26.090, 'CZK'),
('2016-12-31', 100, 'JPY', 21.907, 'CZK'),
('2017-01-12', 1, 'EUR', 121.71586, 'JPY'),
('2017-07-01', 1, 'EUR', 128.42362, 'JPY')
END
Notice that some currency pairs may be switched (like in the first two sample rows).
Then I have whatever table that contains items with price expressed in the local currency. Here is the snippet to create a sample table that will be used by the later code:
IF (OBJECT_ID(N'dbo.order_details', 'U') IS NULL) BEGIN
CREATE TABLE dbo.order_details (
ID int IDENTITY PRIMARY KEY NOT NULL,
date1 date,
order_code varchar(10),
part_no varchar(20),
name nvarchar(100),
price numeric(16, 4),
cur char(3)
)
-- Initial rows.
INSERT INTO dbo.order_details
(date1, order_code, part_no, name, price, cur) VALUES
('2017-09-01', 'A2017001', 'PX105', 'product 1', 10.0000, 'EUR'),
('2017-09-01', 'A2017001', 'T128', 'product 2', 150.0000, 'JPY'),
('2017-09-01', 'A2017001', 'Z15', 'product 3', 25.5000, 'CZK'),
('2017-09-01', 'A2017001', 'int1', 'product 4', 125.0000, 'CZK'),
('2017-09-13', 'A2017002', 'zbo', 'product 5', 250.0000, 'CZK'),
('2017-09-13', 'A2017002', 'zbo2', 'product 6', 1.0000, 'EUR')
END
The full code that should solve the task is presented below. The @currency_to
will become a stored procedure parameter in future -- here fixed to EUR. The code contain:
Firstly, the destination currency is used to reduce the
exchange_rates
rows (here to EUR; say CZK/JPY pairs are not processed). Because of the possiblecur1
andcur2
switch, theSELECT ... UNION ALL SELECT
is used to unify the records into variable table@exchrates
. The two coefficients are calculated to therate
value.Then the temporary table
#exchrates
is used to contains the enhanced rows wheredate_to
column is added (for easier join with the real data later). Here the common table expression (cte) specific to MS-SQL is used for self-join to get thedate_to
from the next record. It isUNION
ed with fictional dates that define the period before the first and after the last exchange rate row. Also the rate 1 (one) is used for the target currency.The last part shows the usage of the above constructed table.
Updated code (2017-10-10; see the original below):
The updated code uses the LEAD()
function to avoid self join with common table expression (cte). On the other hand, I was not able to avoid cte when adding the row that covers the time period before the first explicit row with the rate.
DECLARE @currency_to char(3) = 'EUR'
-- Build the exchange-rate rows for the target currency in the variable.
-- The cur1 and cur2 can be switched in some rows; hence the UNION ALL.
DECLARE @exchrates AS TABLE (
date_from date NOT NULL,
cur1 char(3),
cur2 char(3),
rate float
)
INSERT INTO @exchrates (date_from, cur1, cur2, rate)
SELECT
date_from,
cur1,
cur2,
coef2 / coef1
FROM exchange_rates
WHERE cur2 = @currency_to
UNION ALL
SELECT
date_from,
cur2,
cur1,
coef1 / coef2
FROM exchange_rates
WHERE cur1 = @currency_to
--SELECT * FROM @exchrates
-- The temporary table will contain date intervals when the rate is valid.
CREATE TABLE #exchrates (
date_from date NOT NULL,
date_to date,
cur1 char(3), -- source currency
cur2 char(3), -- destination curr. (only for display; @currency_to is in each row)
rate float
)
;WITH cte AS (
SELECT
*,
ROW_NUMBER() OVER (PARTITION BY cur1 ORDER BY date_from) AS rn
FROM @exchrates
)
INSERT INTO #exchrates (date_from, date_to, cur1, cur2, rate)
-- Records to cover periods before the explicit exchange-rate record...
SELECT
date_from = '1900-01-01',
date_to = DATEADD(DAY, -1, date_from),
cur1,
cur2,
rate
FROM cte
WHERE rn = 1
-- plus records derived from the explicit exchange-rate records...
UNION ALL
SELECT
date_from,
date_to = DATEADD(DAY, -1, COALESCE(LEAD(date_from) OVER (PARTITION BY cur1 ORDER BY date_from), '3000-01-01')),
cur1,
cur2,
rate
FROM @exchrates AS k
-- plus one record for to convert from the target currency to the target currency with rate 1.
UNION ALL
SELECT
date_from = '1900-01-01',
datum_do = '3000-01-01',
@currency_to, @currency_to, 1
SELECT * FROM #exchrates ORDER BY cur1, cur2, date_from
-- Here comes the test of the price recalculation for the products
-- from elsewhere to the currency defined by @currency_to.
SELECT
d.date1,
d.order_code,
d.part_no,
d.name,
d.price,
d.cur,
r.*,
d.price * r.rate AS price2
FROM order_details AS d
LEFT OUTER JOIN #exchrates AS r
ON d.cur = r.cur1 -- r.cur2 is always equal to @currency_to
AND d.date1 BETWEEN r.date_from AND r.date_to
DROP TABLE #exchrates
The original code (see the updated code above):
DECLARE @currency_to char(3) = 'EUR'
-- Build the exchange-rate rows for the target currency in the variable.
-- The cur1 and cur2 can be switched in some rows; hence the UNION ALL.
DECLARE @exchrates AS TABLE (
date_from date NOT NULL,
cur1 char(3),
cur2 char(3),
rate float
)
INSERT INTO @exchrates (date_from, cur1, cur2, rate)
SELECT
date_from,
cur1,
cur2,
coef2 / coef1
FROM exchange_rates
WHERE cur2 = @currency_to
UNION ALL
SELECT
date_from,
cur2,
cur1,
coef1 / coef2
FROM exchange_rates
WHERE cur1 = @currency_to
--SELECT * FROM @exchrates
-- The temporary table will contain date intervals when the rate is valid.
CREATE TABLE #exchrates (
date_from date NOT NULL,
date_to date,
cur1 char(3), -- source currency
cur2 char(3), -- destination curr. (only for display; @currency_to is in each row)
rate float
)
;WITH cte AS (
SELECT date_from,
cur1,
cur2, -- @currency_to is in each row
rate,
ROW_NUMBER() OVER (PARTITION BY cur1 ORDER BY date_from) AS rn,
ROW_NUMBER() OVER (PARTITION BY cur1 ORDER BY date_from DESC) AS rn_desc
FROM @exchrates
)
INSERT INTO #exchrates (date_from, date_to, cur1, cur2, rate)
SELECT
t1.date_from,
DATEADD(DAY, -1, t2.date_from), -- one less day from the next record
t1.cur1,
t1.cur2,
t1.rate
FROM cte AS t1, cte AS t2 -- self join
WHERE t1.cur1 = t2.cur1 AND t1.rn = t2.rn - 1 -- this and next row
UNION ALL
SELECT
'1900-01-01', -- fictional date
DATEADD(DAY, -1, t.date_from), -- one less day than the date
t.cur1,
t.cur2,
t.rate -- the best guess from the first real date
FROM cte AS t
WHERE t.rn = 1 -- period before the first record will have the same rate
UNION ALL
SELECT
t.date_from,
'3000-01-01', -- fictional date to cover period after the last date
t.cur1,
t.cur2,
t.rate
FROM cte AS t
WHERE t.rn_desc = 1 -- rate equal to the last known rate
-- Identity rate for the target currency.
INSERT INTO #exchrates (date_from, date_to, cur1, cur2, rate)
VALUES ('1900-01-01', '3000-01-01', @currency_to, @currency_to, 1)
SELECT * FROM #exchrates ORDER BY cur1, cur2, date_from
-- Here comes the test of the price recalculation for the products
-- from elsewhere to the currency defined by @currency_to.
SELECT
d.date1,
d.order_code,
d.part_no,
d.name,
d.price,
d.cur,
r.*,
d.price * r.rate AS price2
FROM order_details AS d
LEFT OUTER JOIN #exchrates AS r
ON d.cur = r.cur1 AND r.cur2 = @currency_to
AND d.date1 BETWEEN r.date_from AND r.date_to
DROP TABLE #exchrates
The results look like this...
First the #exchrates
:
date_from date_to cur1 cur2 rate
1900-01-01 2016-12-31 CZK EUR 0,0370096225018505
2017-01-01 2017-07-02 CZK EUR 0,0370096225018505
2017-07-03 2017-09-04 CZK EUR 0,0382555470543229
2017-09-05 2017-09-19 CZK EUR 0,0383509108341323
2017-09-20 3000-01-01 CZK EUR 0,0383288616328095
1900-01-01 3000-01-01 EUR EUR 1
1900-01-01 2017-01-11 JPY EUR 0,0082158561751936
2017-01-12 2017-06-30 JPY EUR 0,0082158561751936
2017-07-01 3000-01-01 JPY EUR 0,00778672957513579
And the products with prices (the bar separates the original values from the calculated:
-------------------- from the order rows --------------- ----------------- calculated from the exchange rates table -------------
date1 order_code part_no name price cur | date_from date_to cur1 cur2 rate price2
2017-09-01 A2017001 PX105 product 1 10.0000 EUR | 1900-01-01 3000-01-01 EUR EUR 1 10
2017-09-01 A2017001 T128 product 2 150.0000 JPY | 2017-07-01 3000-01-01 JPY EUR 0,00778672957513579 1,16800943627037
2017-09-01 A2017001 Z15 product 3 25.5000 CZK | 2017-07-03 2017-09-04 CZK EUR 0,0382555470543229 0,975516449885233
2017-09-01 A2017001 int1 product 4 125.0000 CZK | 2017-07-03 2017-09-04 CZK EUR 0,0382555470543229 4,78194338179036
2017-09-13 A2017002 zbo product 5 250.0000 CZK | 2017-09-05 2017-09-19 CZK EUR 0,0383509108341323 9,58772770853308
2017-09-13 A2017002 zbo2 product 6 1.0000 EUR | 1900-01-01 3000-01-01 EUR EUR 1 1
I would like to find a possible design flaws (a better solution). Thanks.
The question is related to https://stackoverflow.com/q/46585724/1346705