Финансовая арифметика в PostgreSQL
Вступление
Иногда перед разработчиком, аналитиком или даже бизнес-пользователем встает задача выполнить какие-то финансовые расчеты, соблюдая два строгих требования. Во-первых, даже для миллиардных сумм необходимо обеспечить точность до копейки, во-вторых, перекрестные итоги тоже должны сходиться до копейки.
Типы данных
Несмотря на то, что во многих источниках для таких расчетов явно рекомендуется использовать десятичные типы данных (decimal/numeric), все же проявим критическое мышление и убедимся в истинности такой рекомендации.
Для начала воспользуемся представлением чисел с плавающей запятой на максимальной точности, доступной в PostgreSQL. Попробуем сложить большое количество чисел. Понятно, что в базе данных это могут быть произвольные числа. Но, для простоты просто сложим миллион раз число 500.05 само собой используя арифметику с плавающей запятой и десятичную:
WITH Src AS (
SELECT 500.05::float8 AS flt_amt,
500.05::decimal(15,2) AS dec_amt
FROM generate_series(1,1000000) G(i) )
SELECT SUM(flt_amt), SUM(dec_amt)
FROM Src;
При суммировании float8 (double precision) получаем 500 050 000.0081566, что, по правилам округления, будет 500 050 000.01. Это явно не корректно. За миллион сложений в формате float8 у нас накопилась ошибка на 1 копейку. Хотя 500 миллионов — совсем не такое уж большое число. А вот при суммировании десятичной арифметикой decimal (15,2), мы получили корректный результат 500 050 000.00
Вот мы и убедились, что считать деньги с точностью до копейки при помощи чисел с плавающей запятой не стоит.
Иногда для рассматриваемых нами расчетов предлагается использовать арифметику с двоичной фиксированной запятой. То есть, все операции выполняются с целыми числами, а в уме мы держим, где находится десятичная точка для каждого числа. Представим ситуацию, когда в магазине на какой-то товар установлена цена 79 руб. 90 коп. Маркетинг подобные цены любит. А в кассовом чеке или отчете нам выделить НДС 18%, входящий в цену:
DO $block$
DECLARE
vat integer = 18;
int_price integer = 7990;
int_vat integer;
dec_price decimal(8,2) = 79.90;
dec_vat decimal(8,2);
BEGIN
int_vat=int_price*vat/(100+vat);
dec_vat=dec_price*vat/(100+vat);
RAISE NOTICE 'int %, dec %', int_vat, dec_vat;
END $block$ LANGUAGE plpgsql;
Результат вычислений с двоичной фиксированной запятой оказался 1218, но так как мы держим в уме, что у нас там два знака после запятой, то интерпретируем его как 12 руб. 18 коп. А вот результат вычислений с десятичной арифметикой оказался 12 руб. 19 коп. Несложно проверить, что именно последний результат корректный, так как 79.90×18/118 ~= 12.1881. Что же произошло?
Проблема в том, двоичная арифметика с фиксированной запятой замечательно подходит для операций сложения, вычитания и умножения. А вот операция деления выполнятся по правилам целочисленного деления, когда результат не округляется, а дробная часть просто отбрасывается. И именно отброшенные 0.81 и привели к ошибке на одну копейку в меньшую сторону.
Исходя из нашего эксперимента можно сделать вывод, что двоичная арифметика с фиксированной запятой для рассматриваемых расчетов тоже не пригодна. Как минимум, если у нас есть операции деления.
Значит следует просто использовать десятичную арифметику и все будет само собой хорошо? А вот и нет.
Перекрестные итоги
Пусть нам надо сформировать и сохранить в БД накладную, счет-фактуру, чек — любой документ, где есть несколько строк и в каждой строке выполняется, для примера, вычисление НДС 18%. При этом, исключительно в качестве примера, чтобы не тревожить Кодда, будем сохранять итоговые суммы в заголовке документа. Для этого создадим следующие таблицы в БД:
CREATE TABLE tmp_bill_hdr (
Id serial NOT NULL PRIMARY KEY,
Num varchar NOT NULL UNIQUE, -- уникальный номер документа
Amt decimal(16,2) NOT NULL DEFAULT 0, -- Сумма по документу без НДС
Vat decimal(16,2) NOT NULL DEFAULT 0 -- Сумма НДС
);
CREATE TABLE tmp_bill_det (
HdrId integer NOT NULL REFERENCES tmp_bill_hdr(Id),
Line integer NOT NULL, -- порядковый номер строки
SKU varchar NOT NULL, -- описание единицы товара
Price decimal(16,2) NOT NULL, -- цена единицы товара
Qty decimal(16,2) NOT NULL, -- количество
Amt decimal(16,2) NOT NULL, -- сумма по строке без НДС
Vat decimal(16,2) NOT NULL, -- НДС 18% по строке
CONSTRAINT tmp_bill_det_PK_Idx PRIMARY KEY (HdrId, Line)
);
Попробуем сформировать новый документ из двух строк с ценой 3 коп. и 10 шт. в одной строке и 4 коп. и тоже 10 шт. во второй строке:
WITH Lines AS (
SELECT D.Line, D.SKU, D.Price, D.Qty,
ROUND(D.Price * D.Qty, 2) AS Amt,
ROUND(D.Price * D.Qty * 0.18, 2) AS Vat
FROM (VALUES
(1, '001-001-0001-01', 0.03, 10),
(2, '001-001-0002-01', 0.04, 10) ) D(Line, SKU, Price, Qty) ),
Hdr AS (
INSERT INTO tmp_bill_hdr (Num, Amt, Vat)
SELECT '001/001-2024', SUM(L.Amt), SUM(L.Vat)
FROM Lines L
RETURNING Id )
INSERT INTO tmp_bill_det (HdrId, Line, SKU, Price, Qty, Amt, Vat)
SELECT H.Id, L.Line, L.SKU, L.Price, L.Qty, L.Amt, L.Vat
FROM Hdr H
CROSS JOIN Lines L;
Пояснения к SQL запросу
Округление при вычислениях в десятичной арифметике необходимо, так как без него в PostgreSQL результат будет иметь максимально доступную точность.
Так как у нас есть внешний ключ и строк на заголовок, для заполнение его в строках удобно использовать возможность указать в CTE INSERT … RETURNING
В итоге у нас получились такие строки:
HdrId | Line | SKU | Price | Qty | Amt | Vat |
1 | 1 | 001–001–0001–01 | 0.03 | 10 | 0.3 | 0.05 |
1 | 2 | 001–001–0002–01 | 0.03 | 10 | 0.4 | 0.07 |
И такой заголовок:
Id | Num | Amt | Vat |
1 | 001/001–2024 | 0.7 | 0.12 |
Вроде бы все хорошо. Но есть проблема. 0.7×0.18=0.126. А значит НДС 18% по документу у нас должен быть 13 копеек, а вовсе на 12.
Что же произошло? А просто 0.3×0.18=0.054, а 0.3×0.18=0.072. Мы корректно округлили НДС до 5 и 7 коп. соответственно, но при этом потеряли копейку в итоге по документу.
Для решения этой проблемы воспользуемся принципом нарастающего итога. НДС по каждой строке мы будем считать как разницу между НДС по сумме всех строк до текущей, включая её, и НДС по сумме всех строк до текущей, не включая её.
WITH Lines AS (
SELECT D.Line, D.SKU, D.Price, D.Qty,
ROUND(D.Price*D.Qty, 2) AS Amt,
ROUND(
SUM(D.Price * D.Qty) OVER (ORDER BY D.Line) * 0.18, 2)
- COALESCE(
ROUND(
SUM(D.Price * D.Qty)
OVER (ORDER BY D.Line
ROWS BETWEEN UNBOUNDED PRECEDING
AND 1 PRECEDING) * 0.18, 2), 0) AS Vat
FROM (VALUES
(1, '001-001-0001-01', 0.03, 10),
(2, '001-001-0002-01', 0.04, 10) ) D(Line, SKU, Price, Qty) ),
Hdr AS (
INSERT INTO tmp_bill_hdr (Num, Amt, Vat)
SELECT '001/002-2024', SUM(L.Amt), SUM(L.Vat)
FROM Lines L
RETURNING Id )
INSERT INTO tmp_bill_det (HdrId, Line, SKU, Price, Qty, Amt, Vat)
SELECT H.Id, L.Line, L.SKU, L.Price, L.Qty, L.Amt, L.Vat
FROM Hdr H
CROSS JOIN Lines L;
В итоге у нас получились такие строки:
HdrId | Line | SKU | Price | Qty | Amt | Vat |
1 | 1 | 001–001–0001–01 | 0.03 | 10 | 0.3 | 0.05 |
1 | 2 | 001–001–0002–01 | 0.03 | 10 | 0.4 | 0.08 |
И такой заголовок:
Id | Num | Amt | Vat |
1 | 001/001–2024 | 0.7 | 0.13 |
Теперь и НДС по документу стал правильный и ошибки округления по строкам мы относим по копейке на ту строку, на которой эта ошибка, с учетом всех предыдущих строк, накопилась.
Как видим, ничего сложного в финансовых расчетах на PostgreSQL нет. Достаточно пользоваться десятичной арифметикой, вовремя округлять результаты вычислений и не забывать распределять накопившуюся ошибку округления по строкам документов.
Спасибо, если дочитали!