© Kim Berg Hansen 2020
K. Berg HansenPractical Oracle SQLhttps://doi.org/10.1007/978-1-4842-5617-6_13

13. Ordered Subsets with Rolling Sums

Kim Berg Hansen1 
(1)
Middelfart, Denmark
 

One of the most useful features of analytic functions is the flexibility of the window clause, enabling aggregation of particular subsets of the data within a specific order. A classic subset that can be used for many purposes is the set of data from the beginning until the current row – if, for example, the sum aggregate function is used on that subset, you get an accumulated sum or rolling sum or running total (many names for the same thing).

The use cases are plenty; many financial reports need running totals. But a different practical use case that has been extremely helpful in my work involves a slight variation of the running total, where I use the sum of all the previous rows to keep selecting rows until I have selected just sufficiently large subset to cover the sum I need – in this case until I have picked enough goods in the warehouse to cover the order by a customer.

The complete case in this chapter will demonstrate the use of analytic functions to solve three problems simultaneously:
  • Picking goods from the inventory in a certain order – most notably in first-in, first-out (FIFO) order

  • Ordering the picking list to make the operator drive optimally through the warehouse

  • Batch picking multiple orders

It can all be done in a single SQL statement, and I’ll show the gradual building of the statement by solving the first problem and then expanding the statement adding the solutions to the second and third problems.

Data for goods picking

When you look at Figure 13-1, there are a lot of tables, mostly to show you a fairly realistic data model. For demonstration purposes, I could have simplified this a lot, but I will do that with a view, as you’ll see shortly.
../images/475066_1_En_13_Chapter/475066_1_En_13_Fig1_HTML.jpg
Figure 13-1

The tables used in this chapter

In the inventory table is stored how many of a given product are currently stored in a given location and from which purchase did that quantity originate (thereby giving us the age of quantity in that location). Basically that’s just foreign keys to locations, products, and purchases tables and then a qty column.

Then there are customers who have given orders that have orderlines specifying which products they are buying, how many, and for how much.

To simplify working with these tables, I create the view inventory_with_dims shown in Listing 13-1. This simply joins the inventory table with the three referenced tables, so that I have all relevant information (product name, purchase date, warehouse, aisle, position) for each inventory row.
create or replace view inventory_with_dims
as
select
   i.id
 , i.product_id
 , p.name as product_name
 , i.purchase_id
 , pu.purchased
 , i.location_id
 , l.warehouse
 , l.aisle
 , l.position
 , i.qty
from inventory i
join purchases pu
   on pu.id = i.purchase_id
join products p
   on p.id = i.product_id
join locations l
   on l.id = i.location_id;
Listing 13-1

View joining inventory with other relevant tables

When I build my picking SQL statement, I’ll be using this view together with the orderlines table.

Building the picking SQL

For the first two parts of the problem, I will just pick a single order, the order with id = 421. In Listing 13-2, I’ll just show you the data of that order.
SQL> select
  2     c.id           as c_id
  3   , c.name         as c_name
  4   , o.id           as o_id
  5   , ol.product_id  as p_id
  6   , p.name         as p_name
  7   , ol.qty
  8  from orders o
  9  join orderlines ol
 10     on ol.order_id = o.id
 11  join products p
 12     on p.id = ol.product_id
 13  join customers c
 14     on c.id = o.customer_id
 15  where o.id = 421
 16  order by o.id, ol.product_id;
Listing 13-2

Data for the order I am going to pick

As you see here in the output, the White Hart pub has ordered 110 of Hoppy Crude Oil and 140 of Der Helle Kumpel:
C_ID   C_NAME          O_ID  P_ID  P_NAME            QTY
50042  The White Hart  421   4280  Hoppy Crude Oil   110
50042  The White Hart  421   6520  Der Helle Kumpel  140

Then it’s time to start building an analytic SQL statement.

Solving picking an order by FIFO

The first thing I do is I join the orderlines of order 421 with the inventory_with_dims view in Listing 13-3.

(Bear with me that I’m using very short column aliases, but it’s an easy way to get a sqlcl output with very narrow columns that fits nicely on print.)
SQL> select
  2     i.product_id as p_id
  3   , ol.qty       as ord_q
  4   , i.qty        as loc_q
  5   , sum(i.qty) over (
  6        partition by i.product_id
  7        order by i.purchased, i.qty
  8        rows between unbounded preceding and current row
  9     )            as acc_q
 10   , i.purchased
 11   , i.warehouse  as wh
 12   , i.aisle      as ai
 13   , i.position   as pos
 14  from orderlines ol
 15  join inventory_with_dims i
 16     on i.product_id = ol.product_id
 17  where ol.order_id = 421
 18  order by i.product_id, i.purchased, i.qty;
Listing 13-3

Possible inventory to pick – in order of purchase date

In lines 5–9 I am doing a rolling sum of the inventory quantity, partitioned by product and ordered by purchase date. And for those cases with multiple rows having the same purchase date, I add the quantity to the ordering, so I get to clean out smaller quantities in the warehouse first.

In this query, the final order by in line 18 matches the columns of the partition by followed by order by in the analytic function. This is not necessary (later I will change this on purpose), but when they match like here, then the optimizer can do both with a single sorting operation.

The output shows me for each of the two ordered products all of the inventory in purchase order, and in column acc_q (accumulated quantity), I can see the rolling sum:
P_ID  ORD_Q  LOC_Q  ACC_Q  PURCHASED   WH  AI  POS
4280  110    36     36     2018-02-23  1   C   1
4280  110    39     75     2018-04-23  1   D   18
4280  110    35     110    2018-06-23  2   B   3
4280  110    34     144    2018-08-23  2   C   20
4280  110    37     181    2018-10-23  1   A   4
4280  110    19     200    2018-12-23  2   C   7
6520  140    14     14     2018-02-26  2   B   5
6520  140    14     28     2018-02-26  1   A   29
6520  140    20     48     2018-02-26  1   C   13
6520  140    24     72     2018-02-26  2   B   26
6520  140    26     98     2018-04-26  2   D   9
6520  140    48     146    2018-04-26  1   A   16
6520  140    70     216    2018-06-26  1   C   5
6520  140    21     237    2018-08-26  2   C   31
6520  140    48     285    2018-08-26  1   D   19
6520  140    72     357    2018-10-26  2   A   1
6520  140    43     400    2018-12-26  1   B   32
So this looks just like what I need, right? When the rolling sum is larger than the ordered quantity, I’ve got enough, right? I’m going to try that in Listing 13-4 by wrapping Listing 13-3 in an inline view and filtering in the where clause.
SQL> select *
  2  from (
...
 20  )
 21  where acc_q <= ord_q
 22  order by p_id, purchased, loc_q;
Listing 13-4

Filtering on the accumulated sum

Did I get the right result? No, not quite:
P_ID  ORD_Q  LOC_Q  ACC_Q  PURCHASED   WH  AI  POS
4280  110    36     36     2018-02-23  1   C   1
4280  110    39     75     2018-04-23  1   D   18
4280  110    35     110    2018-06-23  2   B   3
6520  140    14     14     2018-02-26  2   B   5
6520  140    14     28     2018-02-26  1   A   29
6520  140    20     48     2018-02-26  1   C   13
6520  140    24     72     2018-02-26  2   B   26
6520  140    26     98     2018-04-26  2   D   9

Product 4280 is OK; it just happens that the rolling sum exactly matches the ordered quantity of 110 after picking at three locations. But product 6520 only gets to pick 98, where it should get 140? If you look back at the previous output, you’ll see that by the next location (1 A 16), the rolling sum becomes 146, which is greater than 140 so that row is not included in the output, even though I need to pick most of the quantity of that location.

The problem is that I cannot in the where clause create a filter that will include the first row where the rolling sum is greater than the ordered quantity, but not any more rows than that.

But what I can do is to create a rolling sum that accumulates the previous rows only, rather than including the current row. This is simply done in Listing 13-5 by simply changing the window end point of Listing 13-3 from current row to 1 preceding in line 8.
...
  5   , sum(i.qty) over (
  6        partition by i.product_id
  7        order by i.purchased, i.qty
  8        rows between unbounded preceding and 1 preceding
  9     )            as acc_prv_q
...
Listing 13-5

Accumulated sum of only the previous rows

The rolling sums in this output is pushed one row down when compared to the output of Listing 13-3:
P_ID  ORD_Q  LOC_Q  ACC_PRV_Q  PURCHASED   WH  AI  POS
4280  110    36                2018-02-23  1   C   1
4280  110    39     36         2018-04-23  1   D   18
4280  110    35     75         2018-06-23  2   B   3
4280  110    34     110        2018-08-23  2   C   20
4280  110    37     144        2018-10-23  1   A   4
4280  110    19     181        2018-12-23  2   C   7
6520  140    14                2018-02-26  2   B   5
6520  140    14     14         2018-02-26  1   A   29
6520  140    20     28         2018-02-26  1   C   13
6520  140    24     48         2018-02-26  2   B   26
6520  140    26     72         2018-04-26  2   D   9
6520  140    48     98         2018-04-26  1   A   16
6520  140    70     146        2018-06-26  1   C   5
6520  140    21     216        2018-08-26  2   C   31
6520  140    48     237        2018-08-26  1   D   19
6520  140    72     285        2018-10-26  2   A   1
6520  140    43     357        2018-12-26  1   B   32
This means that the row of product 6520 in location 1 A 16 that was missing in the output of Listing 13-4 is now within the window of rows where acc_prv_q is less than ord_q, so I can create Listing 13-6 that correctly filters what I need. It is the solution to the first problem of the three described at the beginning of the chapter.
SQL> select
  2     wh, ai, pos, p_id
  3   , least(loc_q, ord_q - acc_prv_q) as pick_q
  4  from (
  5     select
  6        i.product_id as p_id
  7      , ol.qty       as ord_q
  8      , i.qty        as loc_q
  9      , nvl(sum(i.qty) over (
 10           partition by i.product_id
 11           order by i.purchased, i.qty
 12           rows between unbounded preceding and 1 preceding
 13        ), 0)        as acc_prv_q
 14      , i.purchased
 15      , i.warehouse  as wh
 16      , i.aisle      as ai
 17      , i.position   as pos
 18     from orderlines ol
 19     join inventory_with_dims i
 20        on i.product_id = ol.product_id
 21     where ol.order_id = 421
 22  )
 23  where acc_prv_q < ord_q
 24  order by wh, ai, pos;
Listing 13-6

Filtering on the accumulation of previous rows

In lines 9–13, I do the rolling sum of previous rows, but note that I need to use nvl to turn the null of the first row into a zero – otherwise, the where clause in line 23 will fail.

That where clause you can read as “As long as the previous row(s) have not yet picked enough to fulfill the order, I need to include this row in the output.”

In line 3, I calculate how much needs to be picked at the location of each row. I know how much still needs to be picked; it’s the ordered quantity (ord_q) minus what has already been picked in the previous rows (acc_prv_q). If this is smaller than what is on the location (loc_q), that is what I need to pick. But if it is greater, then of course I can only pick as much as is on the location. In other words, I need to pick the smaller of the two numbers, which I can do with the least function.

Finally I’ve cleaned up the select list only saving what’s necessary to put on the picking list, and in line 23, I’m ordering the rows in location order:
WH  AI  POS  P_ID  PICK_Q
1   A   16   6520  42
1   A   29   6520  14
1   C   1    4280  36
1   C   13   6520  20
1   D   18   4280  39
2   B   3    4280  35
2   B   5    6520  14
2   B   26   6520  24
2   D   9    6520  26
The picking operator can now take this list and drive around the warehouse picking the goods as specified. He’ll follow the route shown in Figure 13-2.
../images/475066_1_En_13_Chapter/475066_1_En_13_Fig2_HTML.jpg
Figure 13-2

The result of the first version of the FIFO picking query

This route has the problem that after having picked the first two locations in aisle A, he needs to start “from the bottom” in aisle C. That means he either has to turn around (as shown in the figure) or he could take an unnecessary drive “down” aisle B. Neither is really satisfactory, and I’ll come back to the solution of this in a little while.

Easy switch of picking principle

But first I’d like to stress the point that the order by of the query itself and the order by within the analytic function do not have to be identical, as they were in Listing 13-3; they can be different like in the picking list query of Listing 13-6, where I use this fact to select the inventory in FIFO order with the analytic order by, but give the output of the selected rows in location order.

This separation means that I can easily switch picking principle simply by changing my analytic order by, but still get an output in location order.

So for these examples, imagine that beers can keep indefinitely, so it does not matter if I use the first-in, first-out principle or not.

I could then use a picking principle saying that I want to prioritize locations close to the starting point of the driver to give him a short picking route. I just need to change line 11 in Listing 13-6:
...
 11           order by i.warehouse, i.aisle, i.position
...
Selecting inventory to pick in location order gives a short route; he does not have to enter warehouse 2 at all:
WH  AI  POS  P_ID  PICK_Q
1   A   4    4280  37
1   A   16   6520  48
1   A   29   6520  14
1   B   32   6520  43
1   C   1    4280  36
1   C   5    6520  35
1   D   18   4280  37
Or I could use as picking principle that I want the smallest number of picks:
...
 11           order by i.qty desc
...
This will pick from inventories with large quantities first, making it possible to fulfill the order with just five picks:
WH  AI  POS  P_ID  PICK_Q
1   A   4    4280  37
1   C   1    4280  34
1   C   5    6520  68
1   D   18   4280  39
2   A   1    6520  72
But if I pick from large quantities first, then over time the warehouse will be full of locations that have just a small quantity that was “left over” from previous picks. I could choose a picking principle that will clean up such small quantities, freeing the locations for new inventory:
...
 11           order by i.qty
...
Ordering by quantity ascending instead of descending helps cleaning out locations in the warehouse, but of course then the operator has to pick in more places:
WH  AI  POS  P_ID  PICK_Q
1   A   29   6520  14
1   B   32   6520  21
1   C   1    4280  22
1   C   13   6520  20
2   B   3    4280  35
2   B   5    6520  14
2   B   26   6520  24
2   C   7    4280  19
2   C   20   4280  34
2   C   31   6520  21
2   D   9    6520  26

As you can see, having separated the order by that selects the inventory from the order by that controls the picking order, it is easy to switch picking strategies.

With that point made, back to solving the routing problem of Figure 13-2.

Solving optimal picking route

Simply ordering the output in location order means the picking operator needs to drive in the same direction (“upward”) in every aisle – this is not optimal. I’d like him to switch directions so that every other aisle he drives “down.”

But it is not so simple that I can just say up in aisle A and C, down in aisle B and D. Instead I need it to be up in the first, third, fifth…aisle he visits and then down in the second, fourth, sixth…aisle he visits.

To do that, I start by expanding Listing 13-6 with an extra column giving each visited aisle a consecutive number (Listing 13-7).
SQL> select
  2     wh, ai
  3   , dense_rank() over (
  4        order by wh, ai
  5     ) as ai#
  6   , pos, p_id
  7   , least(loc_q, ord_q - acc_prv_q) as pick_q
  8  from (
...
 26  )
 27  where acc_prv_q < ord_q
 28  order by wh, ai, pos;
Listing 13-7

Consecutively numbering visited warehouse aisles

The analytic function dense_rank in lines 3–5 gives the same rank to rows that have the same value in the columns used in the order by. And unlike rank, dense_rank does not skip any numbers (as I showed in Chapter 12); it assigns the ranks consecutively.

So using warehouse and aisle in the order by in dense_rank, the ai# column contains the “visited aisle number” I want:
WH  AI  AI#  POS  P_ID  PICK_Q
1   A   1    16   6520  42
1   A   1    29   6520  14
1   C   2    1    4280  36
1   C   2    13   6520  20
1   D   3    18   4280  39
2   B   4    3    4280  35
2   B   4    5    6520  14
2   B   4    26   6520  24
2   D   5    9    6520  26
That enables me to wrap Listing 13-7 in an inline view to create Listing 13-8 with an odd-even ordering logic.
SQL> select *
  2  from (
...
 30  )
 31  order by
 32     wh, ai#
 33   , case
 34        when mod(ai#, 2) = 1 then +pos
 35                             else -pos
 36     end;
Listing 13-8

Ordering ascending and descending alternately

First, I order by warehouse and visited aisle, but then within each aisle, I use the case structure in lines 33–36 to order the positions ascending in odd numbered aisles and descending in even numbered aisles:
WH  AI  AI#  POS  P_ID  PICK_Q
1   A   1    16   6520  42
1   A   1    29   6520  14
1   C   2    13   6520  20
1   C   2    1    4280  36
1   D   3    18   4280  39
2   B   4    26   6520  24
2   B   4    5    6520  14
2   B   4    3    4280  35
2   D   5    9    6520  26
That gives the operator a better picking route as you can see in Figure 13-3, so Listing 13-8 is the solution to the second of my three problems.
../images/475066_1_En_13_Chapter/475066_1_En_13_Fig3_HTML.jpg
Figure 13-3

Alternating position order of odd/even visited aisles

Again I can show a variation where I can adapt the query very easily to match changing conditions. In Figure 13-3, you see a door between warehouses 1 and 2 both at the bottom and at the top, but what happens if there’s only a door at the bottom and it’s closed at the top?

A small change to the dense_rank call of Listing 13-8 produces Listing 13-9.
...
  5      , dense_rank() over (
  6           partition by wh
  7           order by ai
  8        ) as ai#
...
Listing 13-9

Restarting aisle numbering within each warehouse

All I’ve done is to change an order by warehouse and aisle into a partition by warehouse and order by aisle. The result is that the ranks assigned in column ai# restart from 1 in each warehouse:
WH  AI  AI#  POS  P_ID  PICK_Q
1   A   1    16   6520  42
1   A   1    29   6520  14
1   C   2    13   6520  20
1   C   2    1    4280  36
1   D   3    18   4280  39
2   B   1    3    4280  35
2   B   1    5    6520  14
2   B   1    26   6520  24
2   D   2    9    6520  26

When ai# restarts in each warehouse, that means that aisle B in warehouse 2 changes from being the fourth aisle he visits overall to being the first aisle he visits in warehouse 2. That means it changes from being an even numbered aisle (ordered descending) to being an odd numbered aisle (ordered ascending).

And that gives the picking route shown in Figure 13-4.
../images/475066_1_En_13_Chapter/475066_1_En_13_Fig4_HTML.jpg
Figure 13-4

What happens when there is just one door between warehouses

The first two problems are now solved, so I’ll now move on to the third and last problem.

Solving batch picking

It’s all well and good that I now can pick a single order by FIFO with a good picking route, but to work efficiently, I need the picking operator to be able to pick multiple orders simultaneously in a single drive through the warehouses.

So I’m going to use Listing 13-2 again to show order data, just this time for two other orders. In real life, I’d probably model a “picking batch” table to use for specifying which orders are to be included in a batch, but here I’m just coding the two order ids using in:
...
 15  where o.id in (422, 423)
...
And it shows me two pubs that each have ordered a quantity of both Hoppy Crude Oil and Der Helle Kumpel:
C_ID   C_NAME           O_ID  P_ID  P_NAME            QTY
51069  Der Wichtelmann  422   4280  Hoppy Crude Oil   80
51069  Der Wichtelmann  422   6520  Der Helle Kumpel  80
50741  Hygge og Humle   423   4280  Hoppy Crude Oil   60
50741  Hygge og Humle   423   6520  Der Helle Kumpel  40
I can start simple in Listing 13-10 by just finding the total quantities ordered for each product and then applying the FIFO picking method of Listing 13-6 to those totals.
SQL> with orderbatch as (
  2     select
  3        ol.product_id
  4      , sum(ol.qty) as qty
  5     from orderlines ol
  6     where ol.order_id in (422, 423)
  7     group by ol.product_id
  8  )
  9  select
 10     wh, ai, pos, p_id
 11   , least(loc_q, ord_q - acc_prv_q) as pick_q
 12  from (
 13     select
 14        i.product_id as p_id
 15      , ob.qty       as ord_q
 16      , i.qty        as loc_q
 17      , nvl(sum(i.qty) over (
 18           partition by i.product_id
 19           order by i.purchased, i.qty
 20           rows between unbounded preceding and 1 preceding
 21        ), 0)        as acc_prv_q
 22      , i.purchased
 23      , i.warehouse  as wh
 24      , i.aisle      as ai
 25      , i.position   as pos
 26     from orderbatch ob
 27     join inventory_with_dims i
 28        on i.product_id = ob.product_id
 29  )
 30  where acc_prv_q < ord_q
 31  order by wh, ai, pos;
Listing 13-10

FIFO picking of the total quantities

Using the with clause, I create the orderbatch subquery in lines 1–8 that simply is an aggregation of the ordered quantities per product. The rest of the query is identical to Listing 13-6, except that it uses orderbatch in line 26 instead of table orderlines.

The output is a picking list showing what needs to be picked to fulfill the two orders:
WH  AI  POS  P_ID  PICK_Q
1   A   16   6520  22
1   A   29   6520  14
1   C   1    4280  36
1   C   13   6520  20
1   D   18   4280  39
2   B   3    4280  35
2   B   5    6520  14
2   B   26   6520  24
2   C   20   4280  30
2   D   9    6520  26

But there’s a slight problem for the picking operator – he can see how much to pick, but not how much of that he needs to pack in each order.

To figure that out, I need to calculate some quantity intervals in Listing 13-11.
SQL> with orderbatch as (
...
  8  )
  9  select
 10     wh, ai, pos, p_id
 11   , least(loc_q, ord_q - acc_prv_q) as pick_q
 12   , acc_prv_q + 1       as from_q
 13   , least(acc_q, ord_q) as to_q
 14  from (
 15     select
 16        i.product_id as p_id
 17      , ob.qty       as ord_q
 18      , i.qty        as loc_q
 19      , nvl(sum(i.qty) over (
 20           partition by i.product_id
 21           order by i.purchased, i.qty
 22           rows between unbounded preceding and 1 preceding
 23        ), 0)        as acc_prv_q
 24      , nvl(sum(i.qty) over (
 25           partition by i.product_id
 26           order by i.purchased, i.qty
 27           rows between unbounded preceding and current row
 28        ), 0)        as acc_q
 29      , i.purchased
 30      , i.warehouse  as wh
 31      , i.aisle      as ai
 32      , i.position   as pos
 33     from orderbatch ob
 34     join inventory_with_dims i
 35        on i.product_id = ob.product_id
 36  )
 37  where acc_prv_q < ord_q
 38  order by p_id, purchased, loc_q, wh, ai, pos;
Listing 13-11

Quantity intervals for each pick out of total per product

The inline view in lines 14–36 is almost the same as before, but I have added an extra rolling sum in lines 24–28, so I now have both a rolling sum of the previous rows in acc_prv_q and a rolling sum that includes the current row in acc_q.

With those I can in lines 12–13 calculate the from and to quantity intervals for the row, showing you this output that I’ve ordered in line 38 so that you easily can see what happens with the intervals:
WH  AI  POS  P_ID  PICK_Q  FROM_Q  TO_Q
1   C   1    4280  36      1       36
1   D   18   4280  39      37      75
2   B   3    4280  35      76      110
2   C   20   4280  30      111     140
1   A   29   6520  14      1       14
2   B   5    6520  14      15      28
1   C   13   6520  20      29      48
2   B   26   6520  24      49      72
2   D   9    6520  26      73      98
1   A   16   6520  22      99      120

With these quantity intervals, you can read that the 36 to be picked in the first row are numbers 1-36 out of the total 140 to be picked of product 4280, the 39 in the next row are then numbers 37-75 out of the 140, and so on.

If you’ve a keen eye, you may have spotted that in Listing 13-11, I am actually doing a superfluous analytic function call, since I am using a call both to calculate rolling sum of previous rows and to calculate rolling sum including the current row. But the latter could also be calculated as the rolling sum of previous rows + the quantity in the current row.

So in Listing 13-12, I’ve changed slightly to only do the rolling sum of previous rows in order to save an analytic function call.
SQL> with orderbatch as (
...
  8  )
  9  select
 10     wh, ai, pos, p_id
 11   , least(loc_q, ord_q - acc_prv_q) as pick_q
 12   , acc_prv_q + 1                   as from_q
 13   , least(acc_prv_q + loc_q, ord_q) as to_q
 14  from (
 15     select
 16        i.product_id as p_id
 17      , ob.qty       as ord_q
 18      , i.qty        as loc_q
 19      , nvl(sum(i.qty) over (
 20           partition by i.product_id
 21           order by i.purchased, i.qty
 22           rows between unbounded preceding and 1 preceding
 23        ), 0)        as acc_prv_q
 24      , i.purchased
 25      , i.warehouse  as wh
 26      , i.aisle      as ai
 27      , i.position   as pos
 28     from orderbatch ob
 29     join inventory_with_dims i
 30        on i.product_id = ob.product_id
 31  )
 32  where acc_prv_q < ord_q
 33  order by p_id, purchased, loc_q, wh, ai, pos;
Listing 13-12

Quantity intervals with a single analytic sum

The inline view again only contains the acc_prv_q (as it used to), and then in line 13, I am using acc_prv_q + loc_q instead of the acc_q I no longer have. The result of Listing 13-12 is identical to that of Listing 13-11.

Having quantity intervals for the picks is not enough; I also need similar quantity intervals for the orders, as I show in Listing 13-13.
SQL> select
  2     ol.order_id    as o_id
  3   , ol.product_id  as p_id
  4   , ol.qty
  5   , nvl(sum(ol.qty) over (
  6        partition by ol.product_id
  7        order by ol.order_id
  8        rows between unbounded preceding and 1 preceding
  9     ), 0) + 1      as from_q
 10   , nvl(sum(ol.qty) over (
 11        partition by ol.product_id
 12        order by ol.order_id
 13        rows between unbounded preceding and 1 preceding
 14     ), 0) + ol.qty as to_q
 15  from orderlines ol
 16  where ol.order_id in (422, 423)
 17  order by ol.product_id, ol.order_id;
Listing 13-13

Quantity intervals for each order out of total per product

I’m skipping the inline view here and instead calculate from_q directly in lines 5–9 and to_q in lines 10–14. In both calculations, I’m doing a rolling sum of all previous rows, so that when I’m using the exact same analytic function expression twice, the SQL engine will recognize this and only perform the analytic call once.

The output shows me then that the 80 of product 4280 that is ordered in order 422 are numbers 1-80 out of the 140, just like the picking quantity intervals before.
O_ID  P_ID  QTY  FROM_Q  TO_Q
422   4280  80   1       80
423   4280  60   81      140
422   6520  80   1       80
423   6520  40   81      120
With the two sets of quantity intervals, I can join them where they overlap and that way see how many of each pick go to what order. Listing 13-14 brings the code together.
SQL> with olines as (
  2     select
  3        ol.order_id    as o_id
  4      , ol.product_id  as p_id
  5      , ol.qty
  6      , nvl(sum(ol.qty) over (
  7           partition by ol.product_id
  8           order by ol.order_id
  9           rows between unbounded preceding and 1 preceding
 10        ), 0) + 1      as from_q
 11      , nvl(sum(ol.qty) over (
 12           partition by ol.product_id
 13           order by ol.order_id
 14           rows between unbounded preceding and 1 preceding
 15        ), 0) + ol.qty as to_q
 16     from orderlines ol
 17     where ol.order_id in (422, 423)
 18  ), orderbatch as (
 19     select
 20        ol.p_id
 21      , sum(ol.qty) as qty
 22     from olines ol
 23     group by ol.p_id
 24  ), fifo as (
 25     select
 26        wh, ai, pos, p_id, loc_q
 27      , least(loc_q, ord_q - acc_prv_q) as pick_q
 28      , acc_prv_q + 1                   as from_q
 29      , least(acc_prv_q + loc_q, ord_q) as to_q
 30     from (
 31        select
 32           i.product_id as p_id
 33         , ob.qty       as ord_q
 34         , i.qty        as loc_q
 35         , nvl(sum(i.qty) over (
 36              partition by i.product_id
 37              order by i.purchased, i.qty
 38              rows between unbounded preceding and 1 preceding
 39           ), 0)        as acc_prv_q
 40         , i.purchased
 41         , i.warehouse  as wh
 42         , i.aisle      as ai
 43         , i.position   as pos
 44        from orderbatch ob
 45        join inventory_with_dims i
 46           on i.product_id = ob.p_id
 47     )
 48     where acc_prv_q < ord_q
 49  )
 50  select
 51     f.wh, f.ai, f.pos, f.p_id
 52   , f.pick_q, f.from_q as p_f_q, f.to_q as p_t_q
 53   , o.o_id  , o.from_q as o_f_q, o.to_q as o_t_q
 54  from fifo f
 55  join olines o
 56     on o.p_id = f.p_id
 57     and o.to_q >= f.from_q
 58     and o.from_q <= f.to_q
 59  order by f.p_id, f.from_q, o.from_q;
Listing 13-14

Join overlapping pick and order quantity intervals

I build the query using three with clause subqueries:
  • First I create olines, which is Listing 13-13 calculating the quantity intervals for the orderlines.

  • Then orderbatch, similar to how I did it in Listing 13-12, except that I do the aggregation using olines in line 22 instead of the orderlines table, since olines already has the desired orderlines.

  • The third subquery is fifo, which also comes from Listing 13-12 and takes care of building the FIFO picks including quantity intervals.

The main query then is a join of fifo and olines on the product id and on overlapping quantity intervals. In the resulting output, you see the from/to intervals for the picks as p_f_q/p_t_q and for the orderlines as o_f_q/o_t_q (short column names are good for print):
WH  AI  POS  P_ID  PICK_Q  P_F_Q  P_T_Q  O_ID  O_F_Q  O_T_Q
1   C   1    4280  36      1      36     422   1      80
1   D   18   4280  39      37     75     422   1      80
2   B   3    4280  35      76     110    422   1      80
2   B   3    4280  35      76     110    423   81     140
2   C   20   4280  30      111    140    423   81     140
1   A   29   6520  14      1      14     422   1      80
2   B   5    6520  14      15     28     422   1      80
1   C   13   6520  20      29     48     422   1      80
2   B   26   6520  24      49     72     422   1      80
2   D   9    6520  26      73     98     422   1      80
2   D   9    6520  26      73     98     423   81     120
1   A   16   6520  22      99     120    423   81     120

In the first row, all 36 go to order 422. Likewise in the second row, all 39 go to order 422.

But the next 35 picked are numbers 76-110 (out of 140), which overlaps both with order 422 (numbers 1-80) and order 423 (numbers 81-140). You can see from those overlaps that 5 of the 35 (numbers 76-80) should go to order 422 and the 30 of the 35 (numbers 81-110) should go to order 423.

In Listing 13-15, I calculate this as well as clean up the query a bit to not show the intermediate calculation columns.
SQL> with olines as (
...
 18  ), orderbatch as (
...
 24  ), fifo as (
...
 49  )
 50  select
 51     f.wh, f.ai, f.pos, f.p_id
 52   , f.pick_q, o.o_id
 53   , least(
 54        f.loc_q
 55      , least(o.to_q, f.to_q) - greatest(o.from_q, f.from_q) + 1
 56     ) as q_f_o
 57  from fifo f
 58  join olines o
 59     on o.p_id = f.p_id
 60     and o.to_q >= f.from_q
 61     and o.from_q <= f.to_q
 62  order by f.p_id, f.from_q, o.from_q;
Listing 13-15

How much quantity from each pick goes to which order

Lines 53–56 calculate the “quantity for order” (q_f_o) by taking either the quantity that is on the location or the “size of the interval overlap,” whichever is the smaller of the two. The result is this output with all the necessary information for the picking operator:
WH  AI  POS  P_ID  PICK_Q  O_ID  Q_F_O
1   C   1    4280  36      422   36
1   D   18   4280  39      422   39
2   B   3    4280  35      422   5
2   B   3    4280  35      423   30
2   C   20   4280  30      423   30
1   A   29   6520  14      422   14
2   B   5    6520  14      422   14
1   C   13   6520  20      422   20
2   B   26   6520  24      422   24
2   D   9    6520  26      422   8
2   D   9    6520  26      423   18
1   A   16   6520  22      423   22

That solved the third problem; now all that is needed to complete the solution is to combine the solutions of problems 2 and 3, so the picking operator also can do the batch picking in an efficient picking route.

Finalizing the complete picking SQL

I have Listing 13-15 for batch picking and Listing 13-8 for a good picking route. Combining the two in Listing 13-16 gives me the complete solution.
SQL> with olines as (
...
 18  ), orderbatch as (
...
 24  ), fifo as (
...
 49  ), pick as (
 50     select
 51        f.wh, f.ai
 52      , dense_rank() over (
 53           order by wh, ai
 54        ) as ai#
 55      , f.pos, f.p_id
 56      , f.pick_q, o.o_id
 57      , least(
 58           f.loc_q
 59         , least(o.to_q, f.to_q) - greatest(o.from_q, f.from_q) + 1
 60        ) as q_f_o
 61     from fifo f
 62     join olines o
 63        on o.p_id = f.p_id
 64        and o.to_q >= f.from_q
 65        and o.from_q <= f.to_q
 66  )
 67  select
 68     p.wh, p.ai, p.pos
 69   , p.p_id, p.pick_q
 70   , p.o_id, p.q_f_o
 71  from pick p
 72  order by p.wh
 73         , p.ai#
 74         , case
 75              when mod(p.ai#, 2) = 1 then +p.pos
 76                                     else -p.pos
 77           end;
Listing 13-16

The ultimate FIFO batch picking SQL statement

The with clause subqueries olines , orderbatch, and fifo are the same as Listing 13-15. Then the main query from Listing 13-15 I have put into subquery pick in lines 49–66.

I’ve added the calculation of the “visited aisle number” ai# (from Listing 13-8) in lines 52–54.

Then the main query is simply selecting the necessary information from the pick subquery and using the order by from Listing 13-8 to give an optimal picking route:
WH  AI  POS  P_ID  PICK_Q  O_ID  Q_F_O
1   A   16   6520  22      423   22
1   A   29   6520  14      422   14
1   C   13   6520  20      422   20
1   C   1    4280  36      422   36
1   D   18   4280  39      422   39
2   B   26   6520  24      422   24
2   B   5    6520  14      422   14
2   B   3    4280  35      422   5
2   B   3    4280  35      423   30
2   C   20   4280  30      423   30
2   D   9    6520  26      422   8
2   D   9    6520  26      423   18

Where a location is repeated on the list, like 2 B 3, you can see that it shows 35 should be picked, 5 of which are to be placed in the package for order 422 and 30 are for the package for order 423.

With this list, the picking operator will be led in a good route through the warehouses, picking products for a batch of multiple orders, where the products have been selected by the first-in, first-out principle.

In total this is practically a complete warehouse goods picking app in a single SQL statement.

Lessons learned

This chapter has shown you the building of a single SQL app with multiple uses of analytic functions that have given you knowledge on
  • Using the window clause to apply analytic sum to a subset of the rows to find the subset that gives a sufficiently large result

  • Calculating intervals with analytic rolling sums to find overlapping intervals

  • Assigning dense_rank to results for alternating ascending and descending ordering

When you understand how to build a statement like this piece by piece with analytic functions, you can create many similar statements that contain a lot of business logic, thereby achieving an app with a lot better performance than extracting the data and doing the same logic procedurally.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.144.77.71