В стиле SQL - 3 (распределение сумм)
Apr. 9th, 2014 07:50 pm![[personal profile]](https://www.dreamwidth.org/img/silk/identity/user.png)
Посмотрим на пример из жизни. В разных вариациях он встречался мне раза три как минимум.
Пусть имеется некое подобие складского учета, в котором ведутся только транзакции поступления позиций на склад и выбытия позиций со склада. Атрибутами транзакций являются:
- собственно позиция (item_id) — стол офисный, кадка с фикусом и т. п.
- стоимость одной штуки (cost)
- количество штук (quantity) — положительно для поступлений, отрицательно для выбытий
- дата (trx_date)
При этом так получилось, что для поступлений мы знаем стоимость позиций (сами же покупали), а для выбытий — нет (кладовщик выдает первую попавшуюся подходящую позицию; неизвестно, когда и по какой стоимости она закупалась).
Задача: написать отчет, показывающий стоимость наличных остатков каждой позиции. За неимением реальной информации о выбытиях считать, что позиции уходят со склада в порядке FIFO.
На примере (для одной позиции):
- Поступление 5 штук по цене 1001 руб.
- Поступление 4 штук по цене 1010 руб.
- Выбытие 3 штук
цена поступило выбыло осталось 1001 5 3 2 1010 4 0 4 - Выбытие 3 штук
цена поступило выбыло осталось 1001 5 3+2 0 1010 4 1 3 - Поступление 2 штук по цене 1100 руб.
цена поступило выбыло осталось 1001 5 3+2 0 1010 4 1 3 1100 2 0 2
Итак, отчет должен показать 3×1010 + 2×1100 = 5230.
Лирическое отступление. Числа в примерах лучше подбирать так, чтобы по результату можно было сразу сказать, откуда он взялся. 5230 читается справа налево: осталось 0 от первого поступления, 3 от второго и 2 от третьего, 5 — контрольная цифра. А вот попробуйте понять, что такое 2000 при цене поступлений 1000 и 500.
Процедурное решение вполне очевидно. Например, можно сделать так:
create table transactions( item_id number, quantity number, cost number, trx_date date ); create or replace type trx_rec is object( item_id number, quantity number, cost number ); / create or replace type trx_tab is table of trx_rec; / -- транзакции из примера insert into transactions values(1, +5, 1001, to_date('01.02.2014','DD.MM.YYYY')); insert into transactions values(1, +4, 1010, to_date('02.02.2014','DD.MM.YYYY')); insert into transactions values(1, -3, null, to_date('05.02.2014','DD.MM.YYYY')); insert into transactions values(1, -3, null, to_date('07.02.2014','DD.MM.YYYY')); insert into transactions values(1, +2, 1100, to_date('09.02.2014','DD.MM.YYYY')); -- добавим еще вторую позицию insert into transactions values(2, +3, 300, to_date('03.02.2014','DD.MM.YYYY')); insert into transactions values(2, -1, null, to_date('04.02.2014','DD.MM.YYYY')); set serveroutput on declare trx trx_tab := trx_tab(); qty_so_far number; begin -- читаем поступления в массив (в порядке дат) for i in ( select t.item_id, t.quantity, t.cost from transactions t where t.cost is not null order by t.trx_date ) loop trx.extend; trx(trx.count) := trx_rec(i.item_id, i.quantity, i.cost); end loop; -- обработка выдач for i in ( select t.item_id, sum(t.quantity) quantity from transactions t where t.cost is null group by t.item_id ) loop qty_so_far := -i.quantity; for n in 1 .. trx.count loop if trx(n).item_id = i.item_id and trx(n).quantity > 0 then if trx(n).quantity >= qty_so_far then trx(n).quantity := trx(n).quantity - qty_so_far; exit; else qty_so_far := qty_so_far - trx(n).quantity; trx(n).quantity := 0; end if; end if; end loop; end loop; -- результат for i in ( select t.item_id, sum(t.cost * t.quantity) cost from table(trx) t group by t.item_id order by t.item_id ) loop dbms_output.put_line(i.item_id||': '||i.cost); end loop; end; /
Небольшая оптимизация состоит в том, чтобы списывать сразу общую сумму, а не возиться с каждым выбытием отдельно. Чтобы не засорять код сверх меры, я все-таки перепоручил заключительную часть — группировку сумм по позициям — SQL, обратившись к коллекции как к таблице (select from table()
).
А как это сделать на чистом SQL? Аналитические функции — наше все.
Здесь нам поможет сумма нарастающим итогом. Напомню, что функция sum (как и ряд других функций) может играть несколько ролей:
sum
Агрегатная функция: суммирует все, что сгруппировано с помощьюgroup by
.sum over (partition by ...)
Аналитическая функция: суммирует все, что попадает в окно, заданноеpartition by
.sum over (partition by ... order by ...)
Аналитическая функция: суммирует нарастающим итогом все, что попадает в окно, заданноеpartition by
.
Идею решения удобно показать на том же примере:
поступило | поступило (нарастающим итогом) | всего должно выбыть | разница |
---|---|---|---|
A | B | C | D = B − C |
5 | 5 | 6 | −1 |
4 | 9 | 6 | 3 |
2 | 11 | 6 | 5 |
Возможны три варианта.
- Разница отрицательна (D < 0):
этой и предыдущих транзакций не хватило для выбытия, остаток равен нулю. - Разница меньше количества в данной транзакции (0 ≤ D < A):
этой транзакции уже хватило, остаток равен разнице. - Разница не меньше количества в данной транзакции (D ≥ A):
до списания с этой транзакции дело не дошло.
Собственно запрос:
with transactions(item_id, quantity, cost, trx_date) as ( -- транзакции из примера select 1, +5, 1001, to_date('01.02.2014','DD.MM.YYYY') from dual union all select 1, +4, 1010, to_date('02.02.2014','DD.MM.YYYY') from dual union all select 1, -3, null, to_date('05.02.2014','DD.MM.YYYY') from dual union all select 1, -3, null, to_date('07.02.2014','DD.MM.YYYY') from dual union all select 1, +2, 1100, to_date('09.02.2014','DD.MM.YYYY') from dual union all -- добавим еще вторую позицию select 2, +3, 300, to_date('03.02.2014','DD.MM.YYYY') from dual union all select 2, -1, null, to_date('04.02.2014','DD.MM.YYYY') from dual ), receipts(item_id, quantity, cost, cumulation_qty, trx_date) as ( select item_id, quantity, cost, sum(quantity) over (partition by item_id order by trx_date), trx_date from transactions where cost is not null ), issues(item_id, quantity) as ( select item_id, -sum(quantity) from transactions where cost is null group by item_id ), distributed_qty(item_id, quantity, cost) as ( select r.item_id, case when r.cumulation_qty - i.quantity < 0 then 0 when r.cumulation_qty - i.quantity < r.quantity then r.cumulation_qty - i.quantity else r.quantity end, r.cost from receipts r, issues i where i.item_id = r.item_id ) select item_id, sum(cost * quantity) cost from distributed_qty group by item_id order by item_id;
Вот вроде бы все то же самое, а кода получается в полтора раза меньше.
Небольшое предостережение напоследок: если по правилу сортировки, заданному во фразе over (order by)
, на одно место будут претендовать несколько строк, то результат, скорее всего, будет неожиданным. Например, приведенный запрос отработает некорректно, если на одну дату будут две транзакции. В таком случае, если реальный порядок не важен, а нарастающий итог все-таки нужен, удобно добавлять к сортировке rownum.