Финансовая арифметика в 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 нет. Достаточно пользоваться десятичной арифметикой, вовремя округлять результаты вычислений и не забывать распределять накопившуюся ошибку округления по строкам документов.

Спасибо, если дочитали!


ссылка на оригинал статьи https://habr.com/ru/articles/817225/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *