7

Just like any retail business we have an Orders table and an Inventory table. What I am trying to do is to check Orders for which we have enough stock available to dispatch. A few things I need to consider:

  1. If all the items in an order are available only then consider this order to be “Deliverable”

  2. Check Order's deliverable status in the order of OrderID (int value) .i.e OrderID = 1 then 2 and so on.

  3. Before checking for deliverability of next order, reduce the available of stock for the next order (not update the Inventory table but just take into account the stock quantity that has been already consumed by previous orders).

  4. If we do not have enough stock for 1 or more items in the order, completely ignore the order and do not reduce available stock quantity for the next order to be checked.

In the following example:

  1. Order = 100 is fully deliverable because we have enough stock for all products.
  2. Order = 200 is not fully deliverable because PID 2 requires Qty 5 but we only have 3 left after 2 being consumed by the Order 100
  3. Finally, Order = 300 is also fully deliverable because we have enough stock for all products.

Test data

INSERT INTO @Inventory (PID, Qty)
VALUES  (1  , 10) 
    ,   (2  , 5) 
    ,   (3  , 2)


INSERT INTO @Order (OrderID, PID, Qty)
VALUES  (100 , 1 , 2)   --\
    ,   (100 , 2 , 2)   ----> This order is fully available
    ,   (100 , 3 , 1)   --/

    ,   (200 , 1 , 2)   --\
    ,   (200 , 2 , 5)   ----> This order is not fully available
    ,   (200 , 3 , 1)   --/     because of PID 2 only 3 QTY left

    ,   (300 , 1 , 2)   --\
    ,   (300 , 2 , 2)   ----> This order is fully available
    ,   (300 , 3 , 1);  --/

Expected output:

OrderID Status
------------------------
100     Deliverable
200     NOT Deliverable
300     Deliverable

My attempt: I know that it is far from the actual solution but I still wanted to share what I have been trying :)

WITH OrderCTE AS 
(
    SELECT 
        DENSE_RANK() OVER (ORDER BY OrderID) AS OrderRN
        , OrderID
        , PID
        , Qty
    FROM 
        @Order
)
, CTE AS
(
    SELECT 
        o.OrderID
        , o.PID
        , o.Qty
        , i.Qty - o.Qty AS QtyAvailable
        , o.OrderRN  AS OrderRN
    FROM
        OrderCTE o
    INNER JOIN 
        @Inventory i ON i.PID = o.PID
    WHERE 
        o.OrderID IN (SELECT TOP 1 o.OrderID
                      FROM @Order o
                      WHERE NOT EXISTS (SELECT 1 FROM @Inventory i 
                                        WHERE i.PID = o.PID AND i.Qty < o.Qty)
                      ORDER BY o.OrderID)   

    UNION ALL   

    SELECT 
        o.OrderID
        , o.PID
        , o.Qty
        , o.Qty - c.QtyAvailable
        , c.OrderRN + 1
    FROM
        OrderCTE o
    INNER JOIN 
        @Inventory i ON i.PID = o.PID
    INNER JOIN 
        CTE c ON c.OrderRN + 1 = o.OrderRN AND c.PID = o.PID
    WHERE 
        o.Qty <= c.QtyAvailable
)
SELECT * 
FROM CTE
Vladimir Baranov
  • 31,799
  • 5
  • 53
  • 90
Jack Jones
  • 335
  • 1
  • 2
  • 8
  • 4
    At least have the courtesy to link [your duplicate post](https://dba.stackexchange.com/questions/274897/check-orders-that-can-be-delievered-with-reducing-quantity-from-stock) – SMor Sep 03 '20 at 18:05
  • 6
    For logic life this, iterative solutions *Are* what you need, I am afraid. – Thom A Oct 01 '20 at 11:25
  • Why don't you pre calculate and update some column of Order table ?The most optmize and easysolution in this case is Cursor.Or you can create a job which would periodically update Order table. – KumarHarsh Oct 12 '20 at 05:19

2 Answers2

2

The method below doesn't produce correct results. When I put all pieces together I got:

+---------+--------------------+
| OrderID | OrderIsDeliverable |
+---------+--------------------+
|     100 |                  1 |
|     200 |                  0 |
|     300 |                  0 |
+---------+--------------------+

Order=300 was marked as non deliverable, because my query processes all Products independently and this is not correct. The previous Order=200 hogged the quantity for PID=3, even though this Order=200 was not deliverable overall (based on Products other than PID=3) and it should not affect the following orders. But it did affect the following orders, which is not correct.

I don't see how to write a single query without explicit loop(s) here.

Alas.


You can simulate a loop using recursive CTE.

I will show you a query that does the core thing and leave the rest to you, because overall it becomes too long.

The main idea - you need a running total that resets when it reaches a threshold. There are many questions on this topic, I used this as a basis for my answer.

In the query below I'm looking only at a slice of your data, only at one specific PID = 2.

CTE_RN gives us row numbers to iterate upon. CTE_Recursive is the main loop which checks if the running total exceeds the limit. If it does, it discards the Qty from that Order and sets the OrderIsDeliverable flag.

Query

WITH
CTE_RN
AS
(
    SELECT
        O.OrderID
        ,O.PID
        ,O.Qty
        ,I.Qty AS LimitQty
        ,ROW_NUMBER() OVER (ORDER BY O.OrderID) AS rn
    FROM
        @Order AS O
        INNER JOIN @Inventory AS I ON I.PID = O.PID
    WHERE O.PID = 2 -- this would become a parameter
)
,CTE_Recursive
AS
(
    SELECT
        CTE_RN.OrderID
        ,CTE_RN.PID
        ,CTE_RN.Qty
        ,CTE_RN.LimitQty
        ,CTE_RN.rn
        -- this would generate a simple running total
        --,CTE_RN.Qty AS SumQty

        -- the very first order may exceed the limit
        ,CASE WHEN CTE_RN.Qty > CTE_RN.LimitQty
        THEN 0
        ELSE CTE_RN.Qty
        END AS SumQty
        ,CASE WHEN CTE_RN.Qty > CTE_RN.LimitQty
        THEN 0
        ELSE 1
        END AS OrderIsDeliverable
    FROM
        CTE_RN
    WHERE
        CTE_RN.rn = 1

    UNION ALL

    SELECT
        CTE_RN.OrderID
        ,CTE_RN.PID
        ,CTE_RN.Qty
        ,CTE_RN.LimitQty
        ,CTE_RN.rn
        -- this would generate a simple running total
        --,CTE_RN.Qty + CTE_Recursive.SumQty AS SumQty

        -- check if running total exceeds the limit
        ,CASE WHEN CTE_RN.Qty + CTE_Recursive.SumQty > CTE_RN.LimitQty
        THEN CTE_Recursive.SumQty -- don't increase the running total
        ELSE CTE_RN.Qty + CTE_Recursive.SumQty
        END AS SumQty
        ,CASE WHEN CTE_RN.Qty + CTE_Recursive.SumQty > CTE_RN.LimitQty
        THEN 0
        ELSE 1
        END AS OrderIsDeliverable
    FROM
        CTE_RN
        INNER JOIN CTE_Recursive ON CTE_Recursive.rn + 1 = CTE_RN.rn
)
SELECT * FROM CTE_Recursive
;

Result

+---------+-----+-----+----------+----+--------+--------------------+
| OrderID | PID | Qty | LimitQty | rn | SumQty | OrderIsDeliverable |
+---------+-----+-----+----------+----+--------+--------------------+
|     100 |   2 |   2 |        5 |  1 |      2 |                  1 |
|     200 |   2 |   5 |        5 |  2 |      2 |                  0 |
|     300 |   2 |   2 |        5 |  3 |      4 |                  1 |
+---------+-----+-----+----------+----+--------+--------------------+

Now you need to run this query for each PID. I would wrap this query into a table-valued function with parameter and pass PID as parameter. Maybe you can do it without a function as well. Obviously, to create a function you can't have table variables, you need actual tables to reference in your function, so adjust the code accordingly.

Then call it something like this:

SELECT
    ...
FROM
    @Inventory AS I
    CROSS APPLY dbo.MyFunc(I.PID) AS A

This would return the same number of rows as in the @Order table. Then you need to group this by OrderID and look at the OrderIsDeliverable flag. If this flag is 0 at least once for an Order, this Order is not deliverable.

Something like this:

SELECT
    A.OrderID
    ,MIN(OrderIsDeliverable) AS OrderIsDeliverable
FROM
    @Inventory AS I
    CROSS APPLY dbo.MyFunc(I.PID) AS A
GROUP BY
    A.OrderID
;

Ideally, you should try various approaches (cursor, recursive CTE, etc.), make sure that you have appropriate indexes, measure their performance on your real data and hardware and decide which one to use.

Vladimir Baranov
  • 31,799
  • 5
  • 53
  • 90
  • 1
    I tried something similar, but hit an issue. Say you do the computations for product 1, which removes some orders, then run for product 2, which removes some earlier orders. Some of the removed orders via product 1 may now be able to fulfilled because the previous orders in the list have been removed via product 2). This then potentially makes changes in availability for product 2. My attempt tried to do it via recursive CTE (doing it order by order, determine the flag across all products, then go to the next). But aggregation e.g., MIN(product_available_flag) is not allowed in a recusive CTE. – seanb Oct 05 '20 at 23:29
  • 1
    @seanb, yeah. you need not just a loop, but two nested loops. Also, it is not enough to be able to see the single previous row, we need to see all rows of the previous `Order` and "process" all rows of an `Order` in atomic fashion. In other words, looking just at a single running total is not enough. We need to maintain multiple running totals at once. In your example there are 3 PIDs, so we need 3 running totals. After all, it looks like a classic procedural approach with an explicit loop is better suited here. – Vladimir Baranov Oct 06 '20 at 03:04
  • 1
    Thanks @Vladimir, that's summarising the issue very well. I'm not the OP but I tried answering it as an interesting problem and practice at recursive CTEs - but got stuck at that point. I considered a recursive CTE on one product within one order at a time, but gave up and came to the conclusion at the end of your comment - just do a loop because even if I came up with an answer, it would be too complex. But when you posted table-valued functions may help, I was also interested. – seanb Oct 06 '20 at 03:36
  • @seanb, actually, it might be possible to solve the original problem with CTE. We just need a method to have an array of values. We need to store not a single running total, but as many values, as there are Products. We need an array of running totals. One way to make it in SQL Server is to have a `varbinary` long enough and pack integer 4-byte values in it. Overall, it is not that complex, just ugly and fiddly. – Vladimir Baranov Oct 06 '20 at 05:11
0

EDIT: Because I'm ambitious, I've now also found a solution with CTE. Please give my feedback if you find any bug or incorrect results. My old cursor solution is below.

New code with CTE:

DECLARE @OrderQty TABLE
   (OrderID INT NOT NULL,
    PID INT NOT NULL,
    CountOfOrder INT NOT NULL,
    StockQty INT NOT NULL,
    Qty INT NOT NULL,
    DeliverableOrderQty INT NOT NULL,
    PRIMARY KEY CLUSTERED(OrderID,PID))

INSERT INTO @OrderQty
     (OrderID, PID, CountOfOrder, StockQty, Qty, DeliverableOrderQty) 
SELECT o.OrderID,
     o.PID,
     foo.CountOfOrder,
     foo.StockQty,
     o.Qty,
     foo.StockQty / IIF(o.Qty = 0,1,o.Qty) AS DeliverableOrderQty
FROM   @Order AS o
INNER JOIN (SELECT o.PID,
               COUNT(DISTINCT o.OrderID) AS CountOfOrder,
               i.Qty AS StockQty,
               SUM(o.Qty) AS TotalOrderOty
         FROM   @Order AS o
         INNER JOIN @Inventory AS i ON o.PID = i.PID
         GROUP BY o.PID,
                i.Qty) AS foo ON o.PID = foo.PID

DECLARE @OrdersDeliverableQty TABLE
   (OrderID INT NOT NULL PRIMARY KEY,
    CountOfOrder INT NOT NULL,
    DeliverableQty INT NOT NULL)

INSERT INTO @OrdersDeliverableQty
     (OrderID, CountOfOrder, DeliverableQty) 
SELECT oq.OrderID,
     oq.CountOfOrder,
     MIN(oq.DeliverableOrderQty) AS DeliverableQty
FROM   @OrderQty AS oq
GROUP BY oq.OrderID,
       oq.CountOfOrder

DECLARE @AllOrders TABLE
   (OrderID INT NOT NULL PRIMARY KEY)

INSERT INTO @AllOrders
     (OrderID) 
SELECT o.OrderID
FROM   @Order AS o
GROUP BY o.OrderID

DECLARE @DeliverableOrder TABLE
   (OrderID INT NOT NULL PRIMARY KEY);

WITH CTE_1(RankID, OrderID, PID, StockQty, Qty)
    AS (SELECT RANK() OVER(
            ORDER BY oq.PID,
                    oq.DeliverableOrderQty DESC,
                    oq.Qty,
                    oq.OrderID) AS RankID,
            oq.OrderID,
            oq.PID,
            oq.StockQty,
            oq.Qty
       FROM   @OrderQty AS oq
       INNER JOIN @OrdersDeliverableQty AS ohmttoq ON oq.OrderID = ohmttoq.OrderID
                                            AND oq.DeliverableOrderQty = ohmttoq.DeliverableQty),
    CTE_2(MinRankID, MaxRankID)
    AS (SELECT MIN(c.RankID) AS MinRankID,
            MAX(c.RankID) AS MaxRankID
       FROM   CTE_1 AS c),
    CTE_3(NextRankID, MaxRankID, RankID, OrderID, PID, StockQty, RestQty, Qty)
    AS (SELECT c2.MinRankID + 1 AS NextRankID,
            c2.MaxRankID AS MaxRankID,
            c.RankID,
            c.OrderID,
            c.PID,
            c.StockQty,
            c.StockQty - c.Qty AS RestQty,
            c.Qty
       FROM     CTE_1 AS c
       INNER JOIN CTE_2 AS c2 ON c.RankID = c2.MinRankID
       UNION ALL
       SELECT c3.NextRankID + 1 AS NextRankID,
            c3.MaxRankID,
            c3.NextRankID,
            c1.OrderID,
            c1.PID,
            c1.StockQty,
            CASE
                   WHEN c3.PID = C1.PID
                   THEN c3.RestQty
                ELSE c1.StockQty
            END - c1.Qty AS RestQty,
            c1.Qty
       FROM   CTE_3 AS c3
       INNER JOIN CTE_1 AS c1 ON c3.NextRankID = c1.RankID
       WHERE  c3.NextRankID <= c3.MaxRankID)
    INSERT INTO @DeliverableOrder
         (OrderID) 
    SELECT c.OrderID
    FROM   CTE_3 AS c
    WHERE  c.RestQty >= 0

SELECT ao.OrderID,
     CASE
            WHEN oo.OrderID IS NULL
            THEN 'NOT Deliverable'
        ELSE 'Deliverable'
     END AS STATUS
FROM   @AllOrders AS ao
LEFT JOIN @DeliverableOrder AS oo ON ao.OrderID = oo.OrderID

Test Data:

DECLARE @Inventory TABLE
   (PID INT NOT NULL PRIMARY KEY,
   Qty INT NOT NULL)

DECLARE @Order TABLE
   (OrderID INT NOT NULL,
   PID INT NOT NULL,
   Qty INT NOT NULL,
   PRIMARY KEY CLUSTERED(OrderID,PID))

INSERT INTO @Inventory
     (PID, Qty) 
VALUES (1,10),
       (2,6),
       (3,5)

INSERT INTO @Order
     (OrderID, PID, Qty) 
VALUES (100,1,2), (100,2,2), (100,3,2),
       (200,1,2), (200,2,5), (200,3,1),
       (300,1,2), (300,2,2), (300,3,0),
       (400,1,2), (400,2,1), (400,3,2),
       (500,1,5), (500,2,5), (500,3,5),
       (600,1,1), (600,2,1), (600,3,1),
       (700,1,0), (700,2,1), (700,3,1)

Result:

OrderID Status
100     Deliverable
200     NOT Deliverable
300     Deliverable
400     NOT Deliverable
500     NOT Deliverable
600     Deliverable
700     Deliverable

If you need more information or explanations, leave a comment.

Old code with cursor:

DECLARE @OrderQty TABLE
   (OrderID INT NOT NULL,
    PID INT NOT NULL,
    CountOfOrder INT NOT NULL,
    StockQty INT NOT NULL,
    Qty INT NOT NULL,
    DeliverableOrderQty INT NOT NULL,
    PRIMARY KEY CLUSTERED(OrderID,PID))

INSERT INTO @OrderQty
   (OrderID, PID, CountOfOrder, StockQty, Qty, DeliverableOrderQty) 
SELECT o.OrderID,
      o.PID,
      foo.CountOfOrder,
      foo.StockQty,
      o.Qty,
      foo.StockQty / IIF(o.Qty = 0,1,o.Qty) AS DeliverableOrderQty
FROM   @Order AS o
INNER JOIN (SELECT o.PID,
                COUNT(DISTINCT o.OrderID) AS CountOfOrder,
                i.Qty AS StockQty,
                SUM(o.Qty) AS TotalOrderOty
         FROM   @Order AS o
         INNER JOIN @Inventory AS i ON o.PID = i.PID
         GROUP BY o.PID,
                   i.Qty) AS foo ON o.PID = foo.PID

DECLARE @OrdersDeliverableQty TABLE
   (OrderID INT NOT NULL PRIMARY KEY,
    CountOfOrder INT NOT NULL,
    DeliverableQty INT NOT NULL)

INSERT INTO @OrdersDeliverableQty
   (OrderID, CountOfOrder, DeliverableQty) 
SELECT oq.OrderID,
      oq.CountOfOrder,
      MIN(oq.DeliverableOrderQty) AS DeliverableQty
FROM   @OrderQty AS oq
GROUP BY oq.OrderID,
         oq.CountOfOrder

DECLARE @AllOrders TABLE
   (OrderID INT NOT NULL PRIMARY KEY)

INSERT INTO @AllOrders
      (OrderID) 
SELECT o.OrderID
FROM   @Order AS o
GROUP BY o.OrderID

DECLARE @DeliverableOrder TABLE
   (OrderID INT NOT NULL PRIMARY KEY)


DECLARE @OrderID INT,
      @PID INT,
      @StockQty INT,
      @Qty INT

DECLARE @LastPIDCursor INT

DECLARE @QtyRest INT

DECLARE order_qty_cursor CURSOR
FOR SELECT oq.OrderID,
        oq.PID,
        oq.StockQty,
        oq.Qty
   FROM   @OrderQty AS oq
   INNER JOIN @OrdersDeliverableQty AS ohmttoq ON oq.OrderID = ohmttoq.OrderID
                                        AND oq.DeliverableOrderQty = ohmttoq.DeliverableQty
   ORDER BY oq.PID,
          oq.DeliverableOrderQty DESC,
          oq.Qty


OPEN order_qty_cursor

FETCH NEXT FROM order_qty_cursor INTO @OrderID,
                              @PID,
                              @StockQty,
                              @Qty

WHILE @@Fetch_Status = 0
   BEGIN
      IF @LastPIDCursor IS NULL
        OR @LastPIDCursor <> @PID
         BEGIN
            SET @QtyRest = @StockQty - @Qty
      END
         ELSE
         BEGIN
            SET @QtyRest = @QtyRest - @Qty
      END

      IF @QtyRest >= 0
        AND NOT EXISTS (SELECT 1
                     FROM   @DeliverableOrder
                     WHERE  OrderID = @OrderID) 
         BEGIN
            INSERT INTO @DeliverableOrder
                  (OrderID) 
            VALUES
                  (@OrderID) 
      END

      SET @LastPIDCursor = @PID

      FETCH NEXT FROM order_qty_cursor INTO @OrderID,
                                    @PID,
                                    @StockQty,
                                    @Qty
   END

CLOSE order_qty_cursor

DEALLOCATE order_qty_cursor

SELECT ao.OrderID,
     CASE
            WHEN oo.OrderID IS NULL
            THEN 'NOT Deliverable'
        ELSE 'Deliverable'
     END AS STATUS
FROM   @AllOrders AS ao
LEFT JOIN @DeliverableOrder AS oo ON ao.OrderID = oo.OrderID
droebi
  • 872
  • 1
  • 14
  • 27
  • 1
    Here is one example when your query doesn't produce correct result unfortunately. Change this row in the `Orders` table `(200 , 2 , 5)` into `(200 , 2 , 3)`. It should make the `OrderID=200` deliverable, because `PID=2` now has enough stock for `OrderID=100` and `OrderID=200`. The `OrderID=300` should become non-deliverable, because the stock is depleted by the first two orders. Your query doesn't produce this result. – Vladimir Baranov Oct 06 '20 at 22:42
  • @VladimirBaranov I refactored my code, now i use temp tables to arrange the data and one cursor to get the results – droebi Oct 08 '20 at 13:24
  • 1
    Well, the whole point of this question was about doing it without explicit loop or cursor. Your answer is not wrong, using the cursor for this task seems to be OK, but OP wanted to find a solution without it. – Vladimir Baranov Oct 08 '20 at 22:38
  • @VladimirBaranov you're right, my solution with cursor was not asked, I have now inserted a solution with CTE. – droebi Oct 09 '20 at 09:06