Вступление

Иногда перед разработчиком, аналитиком или даже бизнес-пользователем встает задача выполнить какие-то финансовые расчеты, соблюдая два строгих требования. Во-первых, даже для миллиардных сумм необходимо обеспечить точность до копейки, во-вторых, перекрестные итоги тоже должны сходиться до копейки.

Типы данных

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

Исходя из нашего эксперимента можно сделать вывод, что двоичная арифметика с фиксированной запятой для рассматриваемых расчетов тоже не пригодна. Как минимум, если у нас есть операции деления.

В связи с вопросами в комментариях о типе money, добавлю, что в PostgreSQL этот тип реализован на базе 64-битного целого, использует целочисленную арифметику и, следовательно, имеет точно такие же проблемы с делением, как и двоичное число с фиксированной запятой.

Значит следует просто использовать десятичную арифметику и все будет само собой хорошо? А вот и нет.

Перекрестные итоги

Пусть нам надо сформировать и сохранить в БД накладную, счет-фактуру, чек - любой документ, где есть несколько строк и в каждой строке выполняется, для примера, вычисление НДС 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.04

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.4*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.04

10

0.4

0.08

И такой заголовок:

Id

Num

Amt

Vat

1

001/002-2024

0.7

0.13

Теперь и НДС по документу стал правильный и ошибки округления по строкам мы относим по копейке на ту строку, на которой эта ошибка, с учетом всех предыдущих строк, накопилась.

Как видим, ничего сложного в финансовых расчетах на PostgreSQL нет. Достаточно пользоваться десятичной арифметикой, вовремя округлять результаты вычислений и не забывать распределять накопившуюся ошибку округления по строкам документов.

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

Комментарии (50)


  1. santjagocorkez
    27.05.2024 00:00
    +4

    А что с типом money?


    1. stvoid
      27.05.2024 00:00
      +2

      Поддерживаю вопрос и автору нужно написать статью как устроен этот тип в Postgres.
      Когда игрался просто для себя, то обнаружил, что этот тип поля подтягивает валюту из локали, которые очень желательно установить (на случай, если вы хотите использовать "местную" валюту).
      Но вроде как можно управлять этим по хитрому, задавая временно локаль при вставке значения (но это не точно, а проверять лень).
      Документация прям крайне скупа на счет этого типа https://www.postgresql.org/docs/current/datatype-money.html




    1. Serf1r
      27.05.2024 00:00

      Это обертка над decimal


      1. ptr128 Автор
        27.05.2024 00:00

        Если бы. Это обертка над bigint/int8. То есть двоичное с фиксированной запятой, проблемы которого при делении я показал в статье.


    1. ptr128 Автор
      27.05.2024 00:00

      Тоже самое, что и двоичный с фиксированной запятой, так как это он и есть.


  1. qvan
    27.05.2024 00:00
    +3

    Поправочка. НДС не считается по документу.

    2. Сумма налога, предъявляемая налогоплательщиком покупателю товаров (работ, услуг), исчисляется по каждому виду этих товаров (работ, услуг) как соответствующая налоговой ставке процентная доля ...


    1. ptr128 Автор
      27.05.2024 00:00

      Да, для туториала я взял упрощенный пример и использовал только одну ставку НДС 18%, вместо нескольких. В противном случае итоги считаются по каждой налоговой ставке отдельно.


      1. Stonuml
        27.05.2024 00:00
        +3

        да даже при одинаковой налоговой ставке, сумма налога считается по каждой строке и никогда не считается от суммы документа

        если мы говорим про российский бух учет конечно


        1. ptr128 Автор
          27.05.2024 00:00

          Но кроме бухгалтерского учёта есть учёт налоговый, где именно суммы по каждой налоговой ставке отражаются в счетах-фактурах, книге покупок и книге продаж. И, с точки зрения ФНС, ошибка даже на копейку в этих итоговых суммах может привести к отказу на принятие входящего НДС по этой счет-фактуре к зачёту покупателем.


          1. Stonuml
            27.05.2024 00:00
            +3

            Все верно. И в книгу покупок и продаж попадает именно сумма налога из строк фактуры, а не вычисленная умножением суммы документа на ставку. Это будет та-же самая сумма которая отображается в печатной форме сф в итоговой строке.


            1. ptr128 Автор
              27.05.2024 00:00

              Вот только если при эта сумма не будет равна базовой сумме умноженной на ставку НДС, то такая счет-фактура может быть признана недействительной. Отсюда и размазывание ошибок округления по строкам.


              1. Stonuml
                27.05.2024 00:00
                +2

                Она и не будет. Т,к. если в случае корректировки фактуры разные строки будут давать увеличение и уменьшение суммы, то они попадают в разные книги. Никто не оперирует ндс-ом по документу. Всегда обрабатывается ндс по строкам. А если у вас математика по строке не пойдет - то либо бухгалтерия съест либо потребитель.


                1. ptr128 Автор
                  27.05.2024 00:00

                  А при чем тут корректировка? Речь идет о формировании новой счет-фактуры с одинаковой ставкой НДС по всем строкам.

                  Никто не оперирует ндс-ом по документу.

                  В книгу покупок и книгу продаж заносится именно сумма по документу. И если в строке книги покупок при умножении базы налога на его ставку сумма будет отличаться от суммы налога, то в принять в зачет НДС по этой строке ФНС не позволит.


                  1. Stonuml
                    27.05.2024 00:00
                    +2

                    Ну т.е. по факту вы подгоняете сумму ндс по общую сумму документа? А если в документе товары и услуги облагаются по разному - то считаете сумму ндс по каждой из этих групп и размазываете по строкам документа?
                    Очень странная практика, т.к. работая с кучей заказчиков, ни разу не встречал бухгалтерию которая бы так работала. Наоборот иногда поступают замечания когда сумма по строке документа не идет.
                    И сумму ндс по книге как раз никто не сравнивает. Только в случае книги покупок, чтобы сумма ндс в продажах по реализации шла с суммой ндс в книгах покупок.


                    1. ptr128 Автор
                      27.05.2024 00:00
                      +1

                      Ну т.е. по факту вы подгоняете сумму ндс по общую сумму документа?

                      В примере, так как ставка НДС во всех строках одинаковая - да. На практике - в разрезе ставок НДС.

                      ни разу не встречал бухгалтерию которая бы так работала

                      В связи с тем, что я еще с 90-х годов занимался локализацией зарубежных ERP систем (Platinum, ERA, Navision, Axapta), то сталкивался с подобными требования регулярно. Возможно, среди всей Вашей кучи заказчиков просто не было поставщиков, формирующих счета-фактуры из сотен строк, а на копеечные отклонения в книге покупок ФНС не заостряла внимание. Следует понимать, что для того, чтобы расхождение достигло рубля, на которое уже точно ФНС обратит внимание, счет-фактура должна содержать хотя бы пару сотен строк.

                      И сумму ндс по книге как раз никто не сравнивает.

                      То есть Вы опросили абсолютно всех в РФ бухгалтеров и работников ФНС и не нашли среди них ни одного проверяющего расчет НДС в строках книги покупок? И к тому же уверены, что час назад такой не появился?


          1. Stonuml
            27.05.2024 00:00
            +1

            Самое интересно начинается в книгах покупок, когда идет погашение частями. ( В случае одной авансовой фактуры на несколько документов реализации ) И тут уже лучше доплатить на копейку больше чем меньше. На это как раз ФНС закрывает глаза)


  1. Angry_Man
    27.05.2024 00:00
    +3

    0.3*0.18=0.072

    Тут правильно 0.4*0.18=0.072


    1. ptr128 Автор
      27.05.2024 00:00

      Спасибо! Исправил.


  1. suburg
    27.05.2024 00:00
    +3

    Неплохо бы для начала понять, а правильно ли с точки зрения бизнеса распределять погрешность округления по другим строкам.

    Слышал разные точки зрения от бухгалтеров.


    1. Akina
      27.05.2024 00:00

      Слышал разные точки зрения от бухгалтеров.

      То же самое. Но все булгахтеры едины в том, что погрешность округления распределять по строкам - надо. И после перечисления возможных алгоритмов подавляющее большинство останавливается на том, что погрешность следует распределять по строкам с наибольшими индивидуальными суммами.


      1. falconandy
        27.05.2024 00:00
        +1

        Мне кажется, что общую погрешность точно не надо распределять на те строки, где нет "своей" погрешности - например, на строки без копеек или с 50 копейками (для 18%). А только на строки, где погрешность и возникает.


        1. Akina
          27.05.2024 00:00

          Возможный подход. Но не лучший. Формально, если посчитать относительную погрешность между точным и откорректированным для нивелирования погрешности результатом, то ваш способ даст бОльшее значение максимальной относительной погрешности. Как показательный пример - надо отнять избыточную копейку из трёх сумм, две из них по одной копейке, третья - рубль. По вашей логике следует лишнюю копейку отнять от одной из копеек, обнулив её... Тут видны сразу два косяка - и нулевая итоговая сумма, и разные финальные суммы для тех, что были одинаковы до корректировки.

          Мне, кстати, лет 20 назад приходил квиток на доплату квартплаты в 0-00, причём его присылали аж два раза. Хорошо, что третий раз его включили отдельной строкой в квиток за следующий месяц, а то, может, я б эту задолженность и до сих пор не оплатил...


          1. suburg
            27.05.2024 00:00

            Лично видел систему где все суммы для оплаты в бюджет округлялись вверх - лучше переплатить полкопейки, чем потом доказывать что не верблюд.

            Разные варианты бывают.


          1. ptr128 Автор
            27.05.2024 00:00

            нулевая итоговая сумма

            Это как раз нормально. НДС с суммы 1-2 копеек и так получается нулевым. Базовые суммы то мы не трогаем.

            Как я понимаю, Вы ведете речь уже о расчете базовых сумм умножением цены на количество. В этой сфере нет столь жестких требований, как в налоговом учете, особенно когда этот учет снижает налоговую базу, как в случае с НДС.


          1. falconandy
            27.05.2024 00:00

            Я имел в виду ситуацию примерно как на картинке. Тут получается расхождение в 1 копейку, но если добавить её к первой строке с 100 руб. будет выглядеть как минимум странно: вместо 18 руб почему-то 18.01 руб. По мне так корректнее добавить к одной из других строк, где есть погрешность.


            1. ptr128 Автор
              27.05.2024 00:00

              Проверьте Ваш пример на запросе из статьи. Можете менять как угодно порядок строк. На строку, где нет погрешности при вычислении НДС, накопленная погрешность никогда не попадет.


      1. ptr128 Автор
        27.05.2024 00:00

        Этот способ имеет тот недостаток, что уменьшая относительную погрешность приводит к увеличению абсолютной. И если к погрешности в одну копейку в строке ФНС точно не придерется, то к погрешности в рубль - вполне.

        Одно время с клиентом на эту тему официально общались. Там главбух тоже сначала хотел относить ошибку округления на наибольшие суммы. Привели ему пример счет-фактуру Metro Cash&Carry из нескольких сотен позиций с одинаковой суммой, на которых накапливалась ошибка в минус(!) больше рубля. Если отнести её на единственную строку с максимальной суммой, то получался явный косяк.

        Сейчас ФНС, не редко, спокойно относится к погрешности до рубля. Но как только на строке или в итоге погрешность достигнет рубля - счет-фактуру почти наверняка признают недействительной.


    1. ptr128 Автор
      27.05.2024 00:00

      А тут просто иных вариантов нет, кроме распределения. Например, в книгу покупок и книгу продаж попадает счет-фактура, а не строки по ней.

      При ручном распределении, обычно, списывают все эти копейки на строку с максимальной суммой. При автоматическом - используется предложенный в статье способ размазывания не более, чем по 1 копейке на строку.


  1. Akina
    27.05.2024 00:00

    Теперь и НДС по документу стал правильный и ошибки округления по строкам мы относим по копейке на ту строку, на которой эта ошибка, с учетом всех предыдущих строк, накопилась.

    Давайте сформулируем результат немножко по-другому: применяя такой подход, мы получаем недетерминированный запрос.

    Нет, вы-то как раз от этого подстраховались и ввели поле Line, которое однозначно фиксирует порядок записей в группе. Но чисто по опыту - количество систем, имеющих подобную фичу. можно пересчитать по пальцам одной руки, и ещё 5 штук останется...


    1. ptr128 Автор
      27.05.2024 00:00

      применяя такой подход, мы получаем недетерминированный запрос

      и сами же ответили

      ввели поле Line, которое однозначно фиксирует порядок записей в группе

      Но чисто по опыту - количество систем, имеющих подобную фичу

      У нас опыт разный. Я то застал момент, когда появились счета-фактуры, поставившие на уши всех разработчиков ERP и учетных систем как раз из-за необходимости распределения накопленной ошибки округления по строкам документов. Это сейчас об этом стали забывать, как о давно написанном функционале )


  1. SpiderEkb
    27.05.2024 00:00
    +4

    Хорошая тема. На самом деле объемная. Тут много аспектов разных.

    Работаю в банке, пишу то, что крутится на центральных серверах. И хотя напрямую на связан со денежными расчетами, но основы все равно приходится знать.

    Так вот, все суммы, все расчеты всегда ведутся в миноритарных единицах. Т.е. при том, что формат хранения сумм - фиксированная точка, но формат всегда с 0 знаков после запятой.

    Так сделано потому что банк работает с многими валютами. И не для всех подходит два знака после запятой. Есть валюты (например, бельгийский франк BEF, итальянская лира ITL, японская йена JPY) где миноритарных единиц вообще нет. А есть валюты (бахрейнский динар BHD, оманский реал OMR, тунисский динар TND) где в одной мажоритарной 1000 миноритарных единиц. И чтобы все это привести к единому знаменателю используется такой вот прием - все считается в миноритарных. Ну а при отображении, конвертации уже смотрим по справочнику валют.

    Второй момент - мы используем для бизнес-логики язык RPG (пропертиарный язык IBM, распространен на платформе IBM i на которой работают наши сервера я писал о нем). Там на уровне самого языка поддерживаются все типы данных из БД и арифметика с ними. И там поддерживаются операции с округлением когда для присвоении значения с большим количеством десятичных разрядов переменной с меньшим количеством десятичных разрядов "незначащие" разряды отбрасываются с округлением:

    dcl-s val packed(15: 2); // тип с фиксированной точкой, соответсвующий SQL типу decimal(15, 0)
    
    val = 79.90 *18 / 118; // ~= 12.1881 -последние два разряда будут отброшены, val = 12.18
    eval(h) val = 79.90 * 18 / 118; // а вот тут уже используется команда eval(h) - операция с округлением и val = 12.19

    И есть еще третий момент - тип "промежуточного результата вычислений". Который определяется на этапе компиляции так, чтобы в него гарантированно (без переполнения и потери точности) поместился результат вычислений. И только потом уже этот результат заносится в целевую переменную с округлением или без.

    И тут еще может возникнуть переполнение, которое вызовет системное исключение (результат неправильный, работать дальше нельзя). Которое, если все делать правильно, тоже нужно обрабатывать. Или заранее проверять:

    dcl-s minVal    packed(15: 0) inz(*loval) const;  // Спецальное значение - "минимально возможное значени для данного типа" - в данном случае -999999999999999
    dcl-s maxVal    packed(15: 0) inz(*hival) const;  // Спецальное значение - "максимально возможное значени для данного типа" - в данном случае 999999999999999
    dcl-s interRslt packed(63: 0);                    // Для хранения промежуточного результата
    dcl-s val1      packed(15: 0);
    dcl-s val2      packed(15: 0);
    dcl-s val3      packed(15: 0);
    
    eval(h) interRslt = (val1 * val2) * 18 / 118;
    
    select;
      when interRslt < minVal;
        val3 = minVal;
        // и тут генеируем ошибку "выход за нижнюю границу"
      when interRslt > maxVal;
        val3 = maxVal;
        // и тут генеируем ошибку "выход за верхнюю границу"
      other;
        val3 = interRslt;
        // тут все хорошо - переполнения не будет, результат корректный
    endsl;  

    Примерно как-то так. Если не важно за какую границу выехали, то можно и проще (аналог try/catch)

    dcl-s val1      packed(15: 0);
    dcl-s val2      packed(15: 0);
    dcl-s val3      packed(15: 0);
    
    monitor;
      eval(h) val3 = (val1 * val2) * 18 / 118;
    on-excp 'MCH1210';
      // Перехват исключения о переполнении с кодом MCH1210 - Receiver value too small to hold result
      // возвращаем соотв. ошибку, падения программы при это не происходит т.к. исключение перехвачено и обработано
    endmon;

    Так что поднятая в статье тема финансовых расчетов не такая простая как кажется. И ошибки тут недопустимы ни в коем случае. Потому что

    • речь идет о деньгах

    • речь идет о чужих деньгах

    • речь идет о (потенциально) очень больших чужих деньгах

    Цена ошибки может быть очень высока.


    1. liaf4_4
      27.05.2024 00:00

      я тоже сразу подумал, что хранить и производить вычисления лучше в копейках. а как у вас пользователи вводят значения - сразу в миноритарных единицах или для каждой валюты пересчитываете из маж. в мин.?


      1. SpiderEkb
        27.05.2024 00:00
        +1

        Вот насчет как вводят не в курсе, увы... Я не по этой теме работаю - "автоматизация процессов комплаенс-контроля". Там все больше клиентские данные да всякие злодеи-бармалеи (террористы-экстремисты) - поиск совпадений по данным с данными из списков росфинмониторинга и т.п.

        Просто приходилось в рамках задачи комплаенс-проверок для контроля платежей в системе расчетов проверять "суммы на кредитных/депозитных счетах клиента в рублевом эквиваленте".

        Думаю, что вводят нормально, потом уже в БД это кладется в миноритарных по справочнику валют. Там вообще все сложно - сервера от внешнего мира изолированы. Ввод идет где-нибудь на "внешней системе", дальше через REST API дергается веб-сервис (Java) на нашей UWS шине а он уже дергает связанный с ним сервис-модуль (RPG) на сервере...

        Есть технические "опции" (интерфейсные модули для интерактивной работы с таблицами), позволяющие с таблицами прямо на сервере работать, там, вроде бы, сразу в миноритарных ввод. Но это "для внутреннего пользования" вещи. Они только из внутреннего контура банка доступны (где нет выхода во внешний мир).

        А так да - для каждого счета указывается валюта, есть справочник валют (цифровой код, трехбуквенное обозначение, количество разрядов миноритарных единиц, текущий курс и т.п.). Соответственно к нему обращение, если не рубли.

        Есть правила регулятора - когда с округлением, когда без... Благо язык все это поддерживает (потому, собственно, и используется - на этой платформе на нем более 80% кода написано - там достаточно мощно в плане работы с БД и всяких операций с суммами, датами, временем и т.п. и по скорости хорош).


      1. ptr128 Автор
        27.05.2024 00:00

        производить вычисления лучше в копейках

        Как я указал выше, это приводит к корректному результату только при отсутствии операций деления. Ну или при каждом целочисленном делении нужно выделять так же остаток от деления, сдвигать его на один бит влево и сравнивать с делителем. Если делитель окажется меньше или равен - инкрементировать частное. В качестве примера можно посмотреть, как это делается в dotnet-core функцией VarDecDiv.

        В случае PostgreSQL такой алгоритм - большой геморрой.


        1. SpiderEkb
          27.05.2024 00:00
          +1

          Как я указал выше, это приводит к корректному результату только при отсутствии операций деления. Ну или при каждом целочисленном делении нужно выделять так же остаток от деления, сдвигать его на один бит влево и сравнивать с делителем.

          При чем тут целочисленное деление? Форматы с фиксированной точкой (а все финансовые расчеты делаются в этих форматах), даже если указано 0 знаков после запятой, не целочисленные. Есть формат конечного результата, есть формат промежуточного результата. Промежуточный результат всегда обеспечивает максимальную точность. А дальше уже указываем как его заносить в окончательный - с округлением или без.

          Все это документировано - Precision Rules for Numeric Operations и абсолютно прозрачно и предсказуемо.


          1. ptr128 Автор
            27.05.2024 00:00

            Форматы с фиксированной точкой (а все финансовые расчеты делаются в этих форматах), даже если указано 0 знаков после запятой, не целочисленные.

            Это не так. В том числе и money в PostgreSQL, и Decimal в dotnet-core или Java.

            Поэтому для финансовых расчетов используются либо десятичные форматы (decimal/numeric), либо, как в dotnet-core по моей ссылке, применяется алгоритм коррекции частного после деления.

            Конкретно в RPG, результат деления всегда либо float, либо packed decimal. Но никогда не целое. То есть для Вас в RPG в финансовых расчетах целое прозрачно конвертируется в decimal, что и позволяет избежать проблемы целочисленного деления.


            1. SpiderEkb
              27.05.2024 00:00
              +1

              То есть для Вас в RPG в финансовых расчетах целое прозрачно конвертируется в decimal

              Нет. packed decimal - это нативно поддерживаемый в языке тип данных. Называется packed. 100% соответствует типу decimal в SQL. Вся арифметика с ним реализована не в языке, а в системе, ниже SLIC. Для арифметики используются соотв. MI - ADDN/SUBN/MULT/DIV Что там дальше (ниже) - я не в курсе, ниже SLIC доступ только разработчикам ядра ОС.

              На уровне языка мы используем именно packed (для финансовых расчетов). Расчеты "в копейках" - они не для целочисленности, а потому что количество "копеек в рубле" у разных валют разное. У кого-то "копеек" вообще нет. У кого-то их "в рубле" 1000 штук. Поэтому все суммы в packed(15:0) или packed(23:0)


              1. ptr128 Автор
                27.05.2024 00:00

                packed decimal - это нативно поддерживаемый в языке тип данных

                Я Вам даже больше скажу, он еще на IBM/360 поддерживался аппаратно. Еще с тех были машинные команды арифметических операций с упакованными десятичными числами.

                100% соответствует типу decimal в SQL.

                А вот тут Вы уже заблуждаетесь. SQL есть разные. Например в MS SQL decimal/numeric могут содержать только 38 десятичных знаков. А в PostgreSQL decimal/numeric имеют точность до 131072 десятичных цифр до десятичной точки и 16383 - после. Тогда как RPG, как раз из-за упомянутых мной аппаратных ограничений, поддерживает лишь 32-байтные упакованные десятичные числа - 63 десятичных знака. Поэтому число π на plpgsql я с точностью в 1000 знаков легко посчитаю, а на РПГ это выльется в весьма непростую задачу.

                потому что количество "копеек в рубле" у разных валют разное.

                Понятно, что если в мальтийском скудо 12 тари или 240 грано, то, хоть убейся, в десятичное представление можно засунуть только грано или тари, не рискуя получить бесконечную периодическую дробь.

                Поэтому все суммы в packed(15:0) или packed(23:0)

                И точно в соответствии с выводами статьи используются decimal/numeric, но уж никак не двоичные числа с плавающей или фиксированной запятой.


                1. SpiderEkb
                  27.05.2024 00:00
                  +1

                  А вот тут Вы уже заблуждаетесь. SQL есть разные. Например в MS SQL decimal/numeric могут содержать только 38 десятичных знаков. А в PostgreSQL decimal/numeric имеют точность до 131072 десятичных цифр до десятичной точки и 16383 - после.

                  Тут я неточно выразился. Имел ввиду тот decimal, который используется в DB2. Максимум, если правильно помню, 63 знака и 31 после запятой.

                  В С тут, кстати, есть тип decimal(n,m), в С++ - _DecimalT<n,m>


                  1. ptr128 Автор
                    27.05.2024 00:00
                    +1

                    А я уж думал попросите π посчитать и на опережение попробовал. За 800 циклов 1000 знаков совпали:

                    DO $func$
                    DECLARE
                      arctg5 decimal = 0;
                      arctg239 decimal = 0;
                      x5 decimal = 1/5::decimal;
                      x5s decimal = x5*x5;
                      x239 decimal = 1/239::decimal;
                      x239s decimal = x239*x239;
                      xscale decimal = 1;
                    BEGIN
                      FOR i IN 1..800 LOOP
                        arctg5 = arctg5 + x5/xscale;
                        arctg239 = arctg239 + x239/xscale;
                        x5 = x5*x5s;
                        x239 = x239*x239s;
                        xscale = -sign(xscale) * (abs(xscale) + 2);
                      END LOOP;
                      RAISE NOTICE '%', (4 * arctg5 - arctg239)*4;
                    END $func$ LANGUAGE plpgsql;


                    1. SpiderEkb
                      27.05.2024 00:00
                      +1

                      Ну вообще есть тест на накопление ошибок округления. Называется рекуррентное соотношение Мюллера

                      В среднем, числа с фиксированной точкой показывают примерно в два раза большую устойчивость к накоплению ошибок по сравнению с числами с плавающей точкой.


                      1. ptr128 Автор
                        27.05.2024 00:00
                        +1

                        Согласен с Вами. У меня явно "коленочный" вариант, просто демонстрирующий возможность расчетов с почти произвольной точностью


                      1. SpiderEkb
                        27.05.2024 00:00
                        +1

                        Ну и опять же, все это синтетика немного...

                        Я, повторюсь, мало сталкиваюсь именно с финансовыми расчетами (в основном работа со строками во всех возможных ее проявлениях, ну даты-время еще), но из того что видел - есть некоторое арифметическое действие которое надо произвести. И есть четкие правила - тут округляем, там не округляем. Разрядность как операндов, так и результата всегда зафиксирована.

                        Полученный результат сразу становится новой реальностью, новой точкой отсчета и возможности накопления ошибок тут не просматривается.

                        Так что тут скорее важны возможности фиксированного количества знаков после запятой и округления результата на каждом этапе (ради этого, иногда, несколько действий, которые можно записать в одну строку, приходится разбивать на одиночные действия) в соответствии с требованиями и правилами. Правильность результата - не математическая его точность до 100500-го знака, а соответствие требованиям.


  1. kh0
    27.05.2024 00:00

    А если закостылить для немозгоимения: округляем всегда ндс по каждой позиции в большую сторону,а то что сумма всех ндс будет больше чем ндс от итоговой суммы то и наплевать. Кого волнует, что мы с миллионного оборота на 10000 позиций заплатили в бюджет ндс лишние 100 рублей? зато никакой путаницы.


    1. ptr128 Автор
      27.05.2024 00:00

      Никто так делать не запрещает, но подобный подход не позволил бы продемонстрировать, как оконными функциями элегантно решается проблема распределения ошибки округления.


    1. SpiderEkb
      27.05.2024 00:00

      А разве правилами не регламентировано как тот же НДС исчислять - со всего договора, или по каждой позиции отдельно?

      Ну и как округлять тоже должно быть где-то прописано.


      1. ptr128 Автор
        27.05.2024 00:00
        +1

        Изначально, в постановлении № 914 от 02.12.2000 этот вопрос вообще не рассматривался и ГНИ (ФНС тогда еще не было) трактовали его произвольно. Ввод в действие второй части НК РФ с 01.01.2001 никакой ясности в этом вопросе не внес.

        На данный момент, п.2 ст.168 НК РФ требует расчета НДС по каждому виду этих товаров (работ, услуг). При этом ст. 154, особенно в рамках частичной оплаты, указывает на необходимость суммарного исчисления базы налога, а п.1 ст. 166 требует умножать на ставку НДС эту базу. Что делать, если сумма полученная в соответствии п.1 ст. 166 не совпадает с суммой полученной по п.2 ст.168 - законодатель не разъясняет.

        С точки зрения минфина, ошибки до 1 рубля считаются несущественными для бухгалтерского учета и могут не исправляться при их выявлении. Поэтому ФНС отдает себе отчет, что налогоплательщик через суд вполне может опротестовать решение о недействительности счета-фактуры, если ошибка в ней меньше рубля. Так что проблема округления сейчас действительно актуальна только для счет-фактур из сотен строк.


    1. santjagocorkez
      27.05.2024 00:00

      Вот контрагент-покупатель будет рад! Он ведь отраженный в счёте-фактуре НДС выставит в своей отчетности, как НДС входящий, и на его основе рассчитает свой НДС к уплате. Потом ФНС пересчитает и скажет, что ты ошибся, у тебя переплата, а у твоего контрагента неверно расчитанный НДС, штраф, пени. Ведь твоя счётная ошибка дает тебе право на возмещение излишне уплаченного и, наоборот, не предоставляет прав или освобождения от обязанностей твоего контрагента. С учётом сальдо штраф+пени+доначисленный НДС-твоя переплата по НДС, профицит такой схемы для ФНС не оставит твоему контрагенту шанса избежать веселья с несколькими кругами судов, в лучшем (в финансовом смысле) случае.