Sales History Smoothing

The stock-out smoothing and promotional smoothing are standard part of the 6.2 version of AGR.

Stock Out Smoothing

In out-of-stock smoothing, the sales history is adjusted with the average sales during the periods where there are no sales because of stock-outs.
Several consecutive days are called stock-out period if all of the following applies:

  • the stock during the period is zero or negative
  • there are no sales registered during the period
  • there is at least one sale transaction before the period

The procedures [inv].[histories_bridge_stockouts] and [inv].[histories_bridge_stockouts_one_location] are used in stock out smoothing calculations.
Following configurations can be applied to stock-out smoothing:

  1. Found in core.setting (on stg):

    • stockout_days_back - Default value: 30 (but e.g. 1100 for first run)
      • The number of days back from the current day (today) to find stockout period (for first run you need a high number to reach first day of sale)
    • stockout_days_back_to_calc_avg - Default value: 30
      • Number of days used to calculate average sale.
    • stockout_days_back_to_search_for_sale - Default value: 60
      • Number of days back from the first day of stockout period to search for positive sales.
    • stockout_qty - Default value: 0
      • Usually stockout is defined as 0, but sometimes a higher number is required
    • stockout_stop_bridging_after_days - Default value: 30
      • Maximum length of stockout period
  2. In procedure [inv].[histories_bridge_stockouts_one_location] where the average sale is calculated, you can add/remove tables and change the COALESCE as needed. Also, you can add/remove tables where the “find first sale” is executed.

  3. If you like to run out of stock smoothing on another level than location level, e.g. item level, you can follow these instructions here: https://stackoverflow.com/c/agrdynamics/questions/1181

  4. Add the data serie (bridged_sale in this example) to the data_element_series table.

INSERT INTO data_element_series(
	   [id], [name], [group_id], [system_default_element], [client_config]
       ,[server_config])
  VALUES(
	100, 'bridged_sale', 2, 1, NULL,
	'{
   "caption":"Bridged Sale",
   "description":"Bridged Sale",
   "data_object":{
      "name":"dbo.histories_bridged_sale",
      "data_element_ref_table_id":1,
      "id_column":"item_id",
      "date_column":"history_date",
      "value_column":"value"
                 }
    }'
)
  1. Add the data serie to the chart elements table.
    INSERT [dbo].[chart_elements] ([id], [name], [chart_label], [description], [data_element_id], [starts_stops_today], [aggregation_calc_func], [aggregated_over_period], [hidden_in_charts], [editable], [editable_element_id], [highcharts_series_options])  

    VALUES (100, N'bridged_sale', N'Bridged sale', NULL, 100, N'stops', N'sum', 1, 0, 0, NULL, N'{"id":"Bridged sale","name":"SALE_BRIDGED","data":[],"color":"#089e06","type":"column","index":5,"legendIndex":4,"zIndex":6,"stacking":"normal","stack":3}') 

    GO 
  1. Alter the sale data serie to take the bridged sale into account, see the example below:

Example

If you have customized sale, you should not run the following query blindly. Compare the ‘calculation’ value to the standard and make appropriate adjustments to your setup.

-- Be careful when updating
--UPDATE dbo.data_element_series 
SET server_config = JSON_MODIFY(server_config,'$.generated_view.value_column_calculated.calculation','COALESCE([adjusted_sale],[connected_sale],[bridged_sale],[bridged_promo_sale],[original_sale],0)') 
WHERE name = 'sale'

Stock-out smoothing calculations

The average sale is calculated from the last positive sale found before stockout period.
Otherwise, if no sale is found within the days_back_to_search_for_sale period, the average is simply calculated from the first stockout day. E.g. if the bridged period starts on 31.08 we go [30] days back from 30.08 to calculate average sale.

The average sale for stock out period is calculated with the following formula:

  • SUM(COALESCE(adjusted_sale, connected_sale, promo_smoothing, stockout_smoothing, original_sale, 0)) / @days_back_to_calc_avg

(The variables inside the COALESCE can be changed/removed as needed.)

Notice here that the code smooths on older OOS values if they are in place.
E.g. if there are two OOS periods in the last 30 days, then when calculating the OOS value for the later one it will include earlier stock out OOS values in the bridging.

Code for the procedure [rep].[histories_bridge_stockouts_one_location]
    SET ANSI_NULLS ON
    SET QUOTED_IDENTIFIER ON
    GO



    --
    -- rep.histories_bridge_stockouts_one_location
    --

    IF OBJECT_ID('rep.histories_bridge_stockouts_one_location') IS NOT NULL
        DROP PROCEDURE [rep].[histories_bridge_stockouts_one_location]
    GO
    -- ===============================================================================
    -- Author:      Jorundur Matthiasson
    -- Create date: 02.06.2020
    -- Description: Bridging stockouts for sale for location 
    --
    -- 25.08.2020.RJ  Created
    -- 16.09.2020.JM  DEV-1586 Bridge stockout with new logic
    -- 08.03.2021.JM  DEV-2153 Improve to handle sales inside stockout period
    -- ===============================================================================
    CREATE PROCEDURE [rep].[histories_bridge_stockouts_one_location]
        @lId                           INT = -1,
        @location_id                   INT = NULL,
        @days_back                     INT = 30,   -- Days back to search for stockout (for first run you need a high number to reach first day of sale)
        @days_back_to_calc_avg         INT = 30,   -- Days back to calculate average
        @days_back_to_search_for_sale  INT = 60,   -- Days back to search for sale, if set average is calculated from first positive sale found, if zero average is simply calclulated from first stockout day
        @stockout_qty                  INT = 0,    -- Defined stockout quantity, usually zero but sometimes a higher number is required
        @stop_bridging_after_days      INT = 30    -- Max length of stockout period
    AS
    BEGIN
        SET NOCOUNT ON
        SET ANSI_WARNINGS OFF

        BEGIN TRY
            EXEC log.log_transfer @log_id = @lId, @proc_id = @@PROCID, @extra_info = @location_id

            DECLARE @yesterday DATE = DATEADD(DAY, -1, GETDATE())

            DECLARE @from_date DATE = DATEADD(DAY, -@days_back, @yesterday)

            DECLARE @avg_from_date DATE, @avg_to_date DATE, @avg_sale DECIMAL(18, 4)

            DECLARE @bridged_from_date DATE, @bridged_to_date DATE, @bridged_max_date DATE

            DECLARE @search_for_sale_from_date DATE, @search_for_sale_to_date DATE, @first_sale_date DATE

            DECLARE @description NVARCHAR(255) = 'w.Bridged sale, '+ CONVERT(NVARCHAR, @days_back_to_calc_avg) + ' days'

            DECLARE @item_id INT, @stockout_from_date DATE, @stockout_to_date DATE

            DECLARE @Cursor CURSOR
            SET @Cursor = CURSOR LOCAL FAST_FORWARD
                FOR WITH cte AS
                    (
                        -- All stock rows with lag info
                        SELECT hs.item_id, hs.history_date, stock_value = hs.[value], stock_move_value = ISNULL(hsm.stock_move, 0),
                            lag_history_date = LAG(hs.history_date) OVER (PARTITION BY hs.item_id ORDER BY hs.history_date),
                            lag_stock_value = LAG(hs.[value]) OVER (PARTITION BY hs.item_id ORDER BY hs.history_date)
                        FROM rep.items i
                            INNER JOIN rep.histories_stock hs ON hs.item_id = i.id AND hs.history_date >= @from_date
                            LEFT JOIN rep.histories_sales_and_stock_move hsm ON hsm.item_id = hs.item_id AND hsm.history_date = hs.history_date
                        WHERE i.location_id = @location_id
                    ),
                    cte2 AS
                    (
                        -- Bit columns stock, stock_before and stock_move
                        SELECT item_id, history_date, stock_value, stock_move_value, lag_history_date, lag_stock_value,
                            stock = CASE WHEN stock_value <= @stockout_qty THEN 0 WHEN stock_value > @stockout_qty THEN 1 END,
                            stock_before = CASE WHEN lag_stock_value <= @stockout_qty THEN 0 WHEN ISNULL(lag_stock_value, 1) > @stockout_qty THEN 1 END, -- ISNULL for sale that goes directly to stockout with no sale history (new product)
                            stock_move = CASE WHEN stock_move_value < 0 THEN 1 ELSE 0 END
                        FROM cte
                    ),
                    cte3 AS
                    (
                        -- Adding lead info to get start/end
                        SELECT item_id, history_date, stock, stock_before, stock_move,
                            lead_history_date = LEAD(history_date) OVER (PARTITION BY item_id ORDER BY history_date),
                            lead_stock = LEAD(stock) OVER (PARTITION BY item_id ORDER BY history_date),
                            lead_stock_move = LEAD(stock_move) OVER (PARTITION BY item_id ORDER BY history_date)
                        FROM cte2
                        WHERE (stock = 0 AND stock_before = 1) OR (stock = 1 AND stock_before = 0) OR (stock = 0 AND stock_before = 0 AND stock_move = 1)
                    )
                    SELECT item_id,
                        stockout_from_date = CASE WHEN stock = 0 AND stock_before = 1 AND stock_move = 0 THEN history_date                  -- Normal start (no sale)
                                                    WHEN stock = 0 AND stock_before = 1 AND stock_move = 1 THEN DATEADD(DAY, 1, history_date) -- Normal start (sale, so add one day)
                                                    WHEN stock = 0 AND stock_before = 0 AND stock_move = 1 THEN DATEADD(DAY, 1, history_date) -- Sale in stockout start
                                                    END,
                        stockout_to_date   = CASE WHEN stock = 0 AND stock_before = 1 AND lead_stock = 1 THEN DATEADD(DAY, -1, lead_history_date)                                            -- Normal start :: Normal end
                                                    WHEN stock = 0 AND stock_before = 1 AND lead_stock = 0 AND lead_stock_move = 1 THEN DATEADD(DAY, -1, lead_history_date)                    -- Normal start :: Sale in stockout end
                                                    WHEN stock = 0 AND stock_before = 1 AND lead_history_date IS NULL THEN @yesterday                                                          -- Normal start :: Still going
                                                    WHEN stock = 0 AND stock_before = 0 AND stock_move = 1 AND lead_stock = 1 THEN DATEADD(DAY, -1, lead_history_date)                         -- Sale in stockout start :: Normal end
                                                    WHEN stock = 0 AND stock_before = 0 AND stock_move = 1 AND lead_stock = 0 AND lead_stock_move = 1 THEN DATEADD(DAY, -1, lead_history_date) -- Sale in stockout start :: Sale in stockout end
                                                    WHEN stock = 0 AND stock_before = 0 AND stock_move = 1 AND lead_history_date IS NULL THEN @yesterday                                       -- Sale in stockout start :: Still going
                                                    END
                    FROM cte3
                    WHERE NOT(stock = 1 AND stock_before = 0)
                    ORDER BY 1, 2
            OPEN @Cursor
            FETCH NEXT FROM @Cursor INTO @item_id, @stockout_from_date, @stockout_to_date
            WHILE @@FETCH_STATUS = 0
            BEGIN
                -- Set bridged period
                SET @bridged_from_date = @stockout_from_date
                SET @bridged_to_date = @stockout_to_date
                IF @bridged_to_date > @yesterday
                    SET @bridged_to_date = @yesterday

                IF @bridged_to_date >= @bridged_from_date
                BEGIN
                    -- Cap on @stop_bridging_after_days if needed
                    SET @bridged_max_date = DATEADD(DAY, @stop_bridging_after_days - 1, @bridged_from_date)
                    IF @bridged_to_date > @bridged_max_date
                        SET @bridged_to_date = @bridged_max_date

                    -- Initialize average sale
                    SELECT @avg_from_date = NULL, @avg_to_date = NULL, @avg_sale = NULL

                    -- Set average period
                    IF @days_back_to_search_for_sale > 0
                    BEGIN
                        SET @search_for_sale_to_date = @stockout_from_date
                        SET @search_for_sale_from_date = DATEADD(DAY, -(@days_back_to_search_for_sale - 1), @search_for_sale_to_date)

                        -- Find first sale
                        -- *** *** *** ADD/REMOVE TABLES IF NEEDED *** *** ***
                        ;WITH cte AS
                        (
                            -- original_sale
                            SELECT TOP 1 history_date
                            FROM rep.histories_sale
                            WHERE item_id = @item_id AND history_date BETWEEN @search_for_sale_from_date AND @search_for_sale_to_date AND [value] > 0
                            ORDER BY history_date DESC
                            -- adjusted_sale
                            UNION ALL
                            SELECT TOP 1 history_date
                            FROM rep.histories_adjusted_sale
                            WHERE item_id = @item_id AND history_date BETWEEN @search_for_sale_from_date AND @search_for_sale_to_date AND [value] > 0
                            ORDER BY history_date DESC
                            -- connected_sale
                            UNION ALL
                            SELECT TOP 1 history_date
                            FROM rep.histories_connected_sale
                            WHERE item_id = @item_id AND history_date BETWEEN @search_for_sale_from_date AND @search_for_sale_to_date AND [value] > 0
                            ORDER BY history_date DESC
                            -- stockout_smoothing
                            UNION ALL
                            SELECT TOP 1 history_date
                            FROM rep.histories_bridged_sale
                            WHERE item_id = @item_id AND history_date BETWEEN @search_for_sale_from_date AND @search_for_sale_to_date AND [value] > 0
                            ORDER BY history_date DESC
                            -- promo_smoothing
                            UNION ALL
                            SELECT TOP 1 history_date
                            FROM rep.histories_smoothing_sale
                            WHERE item_id = @item_id AND history_date BETWEEN @search_for_sale_from_date AND @search_for_sale_to_date AND [value] > 0
                            ORDER BY history_date DESC
                        )
                        SELECT @first_sale_date = MAX(history_date) FROM cte

                        IF @first_sale_date IS NOT NULL
                        BEGIN
                            SET @avg_to_date = @first_sale_date
                            SET @avg_from_date = DATEADD(DAY, -(@days_back_to_calc_avg - 1), @avg_to_date)
                        END
                    END
                    ELSE
                    BEGIN
                        SET @avg_to_date = @stockout_from_date
                        SET @avg_from_date = DATEADD(DAY, -(@days_back_to_calc_avg - 1), @avg_to_date)
                    END

                    -- Calculate average sale for x days before stockout period
                    -- *** *** *** ADD/REMOVE TABLES AND CHANGE COALESCE IF NEEDED *** *** ***
                    IF @avg_to_date IS NOT NULL
                    BEGIN
                        ;WITH cte AS
                        (
                            -- original_sale
                            SELECT history_date, original_sale = [value], adjusted_sale = NULL, connected_sale = NULL, stockout_smoothing = NULL, promo_smoothing = NULL
                            FROM rep.histories_sale
                            WHERE item_id = @item_id AND history_date BETWEEN @avg_from_date AND @avg_to_date
                            -- adjusted_sale
                            UNION ALL
                            SELECT history_date, original_sale = NULL, adjusted_sale = [value], connected_sale = NULL, stockout_smoothing = NULL, promo_smoothing = NULL
                            FROM rep.histories_adjusted_sale
                            WHERE item_id = @item_id AND history_date BETWEEN @avg_from_date AND @avg_to_date
                            -- connected_sale
                            UNION ALL
                            SELECT history_date, original_sale = NULL, adjusted_sale = NULL, connected_sale = [value], stockout_smoothing = NULL, promo_smoothing = NULL
                            FROM rep.histories_connected_sale
                            WHERE item_id = @item_id AND history_date BETWEEN @avg_from_date AND @avg_to_date
                            -- stockout_smoothing
                            UNION ALL
                            SELECT history_date, original_sale = NULL, adjusted_sale = NULL, connected_sale = NULL, stockout_smoothing = [value], promo_smoothing = NULL
                            FROM rep.histories_bridged_sale
                            WHERE item_id = @item_id AND history_date BETWEEN @avg_from_date AND @avg_to_date
                            -- promo_smoothing
                            UNION ALL
                            SELECT history_date, original_sale = NULL, adjusted_sale = NULL, connected_sale = NULL, stockout_smoothing = NULL, promo_smoothing = [value]
                            FROM rep.histories_smoothing_sale
                            WHERE item_id = @item_id AND history_date BETWEEN @avg_from_date AND @avg_to_date
                        ),
                        cte2 AS
                        (
                            SELECT history_date, original_sale = SUM(original_sale), adjusted_sale = SUM(adjusted_sale), connected_sale = SUM(connected_sale),
                                stockout_smoothing = SUM(stockout_smoothing), promo_smoothing = SUM(promo_smoothing)
                            FROM cte
                            GROUP BY history_date 
                        )
                        SELECT @avg_sale = ROUND(SUM(COALESCE(adjusted_sale, connected_sale, promo_smoothing, stockout_smoothing, original_sale, 0)) / @days_back_to_calc_avg, 2) FROM cte2
                    END

                    IF @avg_sale > 0
                    BEGIN
                        -- Merge into rep.histories_bridged_sale
                        ;WITH TARGET AS
                        (
                            SELECT item_id, history_date, [value], [origin], [description], created_at, updated_at
                            FROM rep.histories_bridged_sale
                            WHERE item_id = @item_id AND history_date BETWEEN @bridged_from_date AND @bridged_to_date
                        )
                        MERGE INTO TARGET USING
                        (
                            -- Joining on sale table to prevent bridging over sale in stockout period (f.e. if sale=10, stock_move=10-10=0 and stock = 0)
                            SELECT item_id = @item_id, history_date = d.[date]
                            FROM date_table d
                                LEFT JOIN rep.histories_sale s ON s.item_id = @item_id AND s.history_date = d.[date]
                            WHERE d.[date] BETWEEN @bridged_from_date AND @bridged_to_date AND (s.item_id IS NULL OR s.[value] <= 0)
                        )
                        AS SOURCE ON
                        (
                            SOURCE.item_id = TARGET.item_id AND SOURCE.history_date = TARGET.history_date
                        )
                        WHEN MATCHED AND TARGET.[value] != @avg_sale THEN
                            UPDATE SET TARGET.[value] = @avg_sale, TARGET.updated_at = GETDATE()
                        WHEN NOT MATCHED BY TARGET THEN
                            INSERT (item_id, history_date, [value], origin, [description], updated_at, created_at)
                            VALUES (SOURCE.item_id, SOURCE.history_date, @avg_sale, '0', @description, GETDATE(), GETDATE());
                    END
                END

                FETCH NEXT FROM @Cursor INTO @item_id, @stockout_from_date, @stockout_to_date
            END
            CLOSE @Cursor
            DEALLOCATE @Cursor
        END TRY
        BEGIN CATCH
        PRINT ERROR_MESSAGE()
        EXEC log.log_error @lId
        END CATCH

        EXEC log.log_transfer_step_duration @log_id = @lId, @proc_id = @@PROCID
    END
    GO
Code for the procedure [rep].[histories_bridge_stockouts]
    IF OBJECT_ID('rep.histories_bridge_stockouts') IS NOT NULL
        DROP PROCEDURE [rep].[histories_bridge_stockouts]
    GO
    -- ================================================================================
    -- Author:      Jorundur Matthiasson
    -- Create date: 16.09.2020
    -- Description: Bridging stockouts for sale
    --              Cursor calling rep.histories_bridge_stockouts_one_location for each location_id
    --
    -- 16.09.2020.JM  Created
    -- ================================================================================
    CREATE PROCEDURE [rep].[histories_bridge_stockouts]
        @lId INT = -99
    AS
    BEGIN
        SET NOCOUNT ON

        PRINT '#'
        PRINT '# rep.histories_bridge_stockouts'
        PRINT '#'
        PRINT ''

        EXEC @lId = log.log_transfer @log_id = @lId, @proc_id = @@PROCID

        DECLARE @location_id INT
    
        -- *** *** *** CHANGE CURSOR SELECT FOR CUSTOMIZED LOCATIONS *** *** ***
        DECLARE @cursor CURSOR
        SET @cursor = CURSOR LOCAL FAST_FORWARD
            FOR SELECT id FROM rep.locations WHERE location_type IN ('warehouse') ORDER BY id
        OPEN @cursor
        FETCH NEXT FROM @cursor INTO @location_id
        WHILE @@FETCH_STATUS = 0
        BEGIN
            PRINT CONVERT(VARCHAR, SYSDATETIME(), 120) + ' : location_id = ' + CONVERT(VARCHAR, @location_id)
            RAISERROR ('', 0, 1) WITH NOWAIT

            EXEC rep.histories_bridge_stockouts_one_location
                @lId,
                @location_id,
                @days_back                    = 30,  -- Days back to search for stockout (for first run you need a high number to reach first day of sale)
                @days_back_to_calc_avg        = 30,  -- Days back to calculate average
                @days_back_to_search_for_sale = 60,  -- Days back to search for sale, if set average is calculated from first positive sale found, if zero average is simply calclulated from first stockout day
                @stockout_qty                 = 0,   -- Defined stockout quantity, usually zero but sometimes a higher number is required
                @stop_bridging_after_days     = 30   -- Max length of stockout period

            FETCH NEXT FROM @cursor INTO @location_id
        END
        CLOSE @cursor
        DEALLOCATE @cursor

        EXEC log.log_transfer_step_duration @log_id = @lId, @proc_id = @@PROCID
    END
    GO




Promotional Smoothing

For promotion periods, the sales history is replaced with average sales before promotions. That is called promotional smoothing and is used to reduce the effect of promotions on the forecast.
The procedure with the promotional smoothing calculations is [inv].[histories_smoothing].
Following configurations can be applied to promo smoothing:

  1. Found in core.setting (on stg):

    • promo_days_back - Default value: 360
      • Number of days back from the current day (today) to search for promotions
    • promo_days_back_to_calc_avg - Default value: 30
      • Number of days back from the date with the last positive sale, before promotions period, to calculate average sale
    • promo_days_back_to_search_for_sale - Default value: 365
      • Number of days from the first day of promotional period to search for positive sales
  2. You can customize the view [inv].[v_histories_smoothing_source] to point to the table where to take the promotion days from. Default is the [inv].[planner_promo] table.

Example of promo smoothing calculations

The following table contains sale data for November 2019 and two weeks in December, but in December there was promotion (but only on weekdays, that’s why there is regular sale on 08.12.19).
The promotional smoothing for promotions period 02.12-06.12.2019 is calculated as the average sale from last 30 days. That gives 648.92.
Then for promotions period 09.12-13.12.2019, the last sale was on 08.12.19. Since 5 days within the 30 day period are promotions and values from promotions are not taken into account, there are fewer days here that are included in the average. Therefore the average sale value for period 09.12-13.12.2019 is 628.33.

history_date    sale_value  adjusted_sale   previous_sale_date      marked_as_bridged
11/1/2019	    681	        681			    -                       0
11/3/2019	    387	        387			    -                       0      
11/4/2019	    299	        299			    -                       0
11/5/2019	    365	        365			    -                       0
11/6/2019	    579 	    579			    -                       0
11/7/2019	    1647	    1647			-                       0
11/8/2019	    1001	    1001			-                       0
11/10/2019	    283	        283			    -                       0
11/11/2019	    429	        429			    -                       0
11/12/2019		236			236				-						0
11/13/2019		739			739				-						0
11/14/2019		1259		1259			-						0
11/15/2019		629			629				-						0
11/17/2019		286			286				-						0
11/18/2019		256			256				-						0
11/19/2019		375			375				-						0
11/20/2019		398			398				-						0
11/21/2019		1749		1749			-						0
11/22/2019		802			802				-						0
11/24/2019		219			219				-						0
11/25/2019		363			363				-						0
11/26/2019		394			394				-						0
11/27/2019		560			560				-						0
11/28/2019		1541		1541			-						0
11/29/2019		1050		1050			-						0
12/1/2019		345			345				-						0
12/2/2019		0			648.92			12/1/2019				1
12/3/2019		0			648.92			12/1/2019				1
12/4/2019		0			648.92			12/1/2019				1
12/5/2019		0			648.92			12/1/2019				1
12/6/2019		0			648.92			12/1/2019				1
12/8/2019		281			281				-						0
12/9/2019		0			628.33			12/8/2019				1
12/10/2019		0			628.33			12/8/2019				1
12/11/2019		0			628.33			12/8/2019				1
12/12/2019		0			628.33			12/8/2019				1
12/13/2019		0			628.33			12/8/2019				1