logo

Are you need IT Support Engineer? Free Consultant

Reducing MRP Purchase Requisitions Using BOM Subst…

  • By Sanjay
  • 30/04/2026
  • 3 Views


This post documents a real implementation of BAdI PPH_SUPPLY_DEMAND_LIST in S/4HANA Public Cloud to bridge the gap left by the absence of Manufacturer Part Number (MPN) functionality. We show how to make MRP consider substitute material stock when calculating purchase requisitions — reducing unnecessary procurement. 14 test iterations, 10 debug versions, and one SAP incident ticket later, here is everything we learned.

The Business Problem

In manufacturing, a Bill of Materials (BOM) often defines alternative item groups — components that are interchangeable substitutes for each other. For example, a finished product FG02 might use either Raw Material A (primary, priority 1) or Raw Material B (substitute, priority 2).

In classic SAP ERP, Manufacturer Part Number (MPN) functionality handled material substitution during MRP planning. However, manufacturing MPN is not supported in S/4HANA Public Cloud. This creates a gap: when the primary component has a shortage, MRP generates a full purchase requisition without considering that a substitute material might already have sufficient stock sitting in the warehouse.

Our scenario:

Material Role Stock Dependent Requirement Shortage

TD_LQ_ROH_A Primary (Priority 1) 200 PC -450 PC 250 PC
TD_LQ_ROH_B Substitute (Priority 2) 200 PC 0 None

Without intervention, MRP generates a purchase requisition for 250 PC of ROH_A — completely ignoring the 200 PC of ROH_B sitting unused in the warehouse. With substitute stock considered, the purchase requisition should be only 50 PC.

Before MRP run on single material TD_LQ_FG02 :

Qi_Liu_0-1777041829692.Png

Qi_Liu_1-1777041829697.Png

Qi_Liu_2-1777041829702.Png

After MRP run:

Qi_Liu_3-1777041829707.Png

Qi_Liu_4-1777041829712.Png

Qi_Liu_5-1777041829717.Png


The Solution Approach

Since manufacturing MPN is unavailable in S/4HANA Public Cloud, we use the BAdI PPH_SUPPLY_DEMAND_LIST — a developer extensibility enhancement point that lets you modify the stock/requirements list during MRP planning. This BAdI is implemented as an AMDP (ABAP-Managed Database Procedure), meaning all logic is written in SQLScript and executes directly on the HANA database.

The strategy is straightforward:

  1. When MRP processes the primary material (ROH_A): Add a virtual supply element (+200 PC) representing the available substitute stock → reduces the purchase requisition from 250 to 50.
  2. When MRP processes the substitute material (ROH_B): Add a virtual demand element (-200 PC) to reserve that stock → prevents it from appearing as excess inventory.

Expected result after BAdI:

Material Stock BAdI Element Dep. Requirement Available Purchase Req.

ROH_A 200 +200 (IndReq supply) -450 -50 50 PC (was 250)
ROH_B 200 -200 (IndReq demand) 0 0 PC (stock reserved)  

Prerequisites and Setup

System: S/4HANA Public Cloud (any version supporting developer extensibility)

BOM Configuration: The finished good (FG02) must have a BOM where both components share the same Alternative Item Group with different priorities:

Component Alt. Item Group Priority

TD_LQ_ROH_A 01 1 (primary)
TD_LQ_ROH_B 01 2 (substitute)

Custom CDS View — Z_BOM_ALT_GROUP_VIEW:

This view joins BOM header and item data to expose the alternative group relationships:

 

@AccessControl.authorizationCheck: #NOT_REQUIRED
@EndUserText.label: 'BOM Alternative Group View for AMDP'

define view entity Z_BOM_ALT_GROUP_VIEW as select from I_MaterialBOMLink as bom
  inner join I_BillOfMaterialItemBasic as item
    on  item.BillOfMaterial         = bom.BillOfMaterial
    and item.BillOfMaterialCategory = bom.BillOfMaterialCategory
{
  key item.BillOfMaterialComponent  as ComponentMaterial,
  key bom.Plant                     as Plant,
  key item.BillOfMaterial           as BomNumber,
      bom.BillOfMaterialVariant     as BomAlternative,
      item.BillOfMaterialItemCategory as ItemCategory,
      item.AlternativeItemGroup     as AlternativeGroup,
      item.AlternativeItemPriority  as Priority,
      bom.Material                  as HeaderMaterial
}
where
  item.AlternativeItemGroup != ''

The Implementation

AMDP Class — ZCL_PPH_SUPPLY_DEMAND_LIST

 

CLASS zcl_pph_supply_demand_list DEFINITION
  PUBLIC FINAL CREATE PUBLIC.

  PUBLIC SECTION.
    INTERFACES if_pph_supply_demand_list.
    INTERFACES if_amdp_marker_hdb.

ENDCLASS.

CLASS zcl_pph_supply_demand_list IMPLEMENTATION.

  METHOD if_pph_supply_demand_list~modify_supply_demand_list
    BY DATABASE PROCEDURE FOR HDB
    LANGUAGE SQLSCRIPT
    OPTIONS READ-ONLY
    USING z_bom_alt_group_view.

    DECLARE lt_add TABLE LIKE :ct_supplydemanditemlist;
    DECLARE lv_material NVARCHAR(40);
    DECLARE lv_plant    NVARCHAR(4);

    -- Step 1: Identify the current material being processed
    SELECT Material INTO lv_material FROM :ct_supplydemanditemlist__in__ LIMIT 1;
    SELECT MRPPlant INTO lv_plant    FROM :ct_supplydemanditemlist__in__ LIMIT 1;

    -- Step 2: Check if current material is a PRIMARY
    lt_substitutes = SELECT alt.ComponentMaterial AS SubMaterial,
                       CAST(alt.Priority AS INTEGER) AS SubPriority
                     FROM z_bom_alt_group_view AS pr
                     INNER JOIN z_bom_alt_group_view AS alt
                       ON  alt.Plant            = pr.Plant
                       AND alt.BomNumber        = pr.BomNumber
                       AND alt.AlternativeGroup = pr.AlternativeGroup
                       AND alt.ComponentMaterial != pr.ComponentMaterial
                       AND CAST(alt.Priority AS INTEGER) > CAST(pr.Priority AS INTEGER)
                     WHERE pr.ComponentMaterial = :lv_material
                       AND pr.Plant             = :lv_plant;

    IF EXISTS (SELECT 1 FROM :lt_substitutes) THEN

      -- PRIMARY BRANCH: Add supply from substitute stock
      lt_stock_source = SELECT 'TD_LQ_ROH_B' AS Material, '1320' AS Plant,
                               CAST(200 AS DECIMAL(31,3)) AS TotalStock
                        FROM dummy;

      lt_sub_total = SELECT CAST(COALESCE(SUM(stk.TotalStock), 0) AS DECIMAL(31,3))
                            AS SubTotal
                     FROM :lt_substitutes AS sub
                     INNER JOIN :lt_stock_source AS stk
                       ON stk.Material = sub.SubMaterial
                       AND stk.Plant = :lv_plant;

      lt_shortage = SELECT
                      CAST(GREATEST(
                        COALESCE(SUM(CASE WHEN MRPElementType = '-'
                                     THEN ABS(MRPElementOpenQuantity) ELSE 0 END), 0)
                        - COALESCE(SUM(CASE WHEN MRPElementType = '+'
                                       THEN MRPElementOpenQuantity ELSE 0 END), 0),
                      0) AS DECIMAL(31,3)) AS Shortage
                    FROM :ct_supplydemanditemlist__in__;

      lt_offset = SELECT CAST(GREATEST(LEAST(t.SubTotal, s.Shortage), 0)
                         AS DECIMAL(31,3)) AS OffsetQty
                  FROM :lt_sub_total AS t
                  CROSS JOIN :lt_shortage AS s
                  WHERE t.SubTotal > 0 AND s.Shortage > 0;

      IF EXISTS (SELECT 1 FROM :lt_offset WHERE OffsetQty > 0) THEN

        INSERT INTO :lt_add(MRPElementAvailyOrRqmtDate, MRPElementOpenQuantity,
                            MRPElementCategory, MRPElementType, MRPAvailability)(
          SELECT TO_CHAR(CURRENT_DATE, 'YYYYMMDD'),
                 OffsetQty, 'PP', '+', 'X'
          FROM :lt_offset );

        UPDATE :lt_add SET ProcessingKey          = 'A',
                            MRPPlanningSegmentType = '02',
                            MRPPlanningSegment     = '',
                            MRPPlant               = :lv_plant,
                            MRPArea                = :lv_plant,
                            Material               = :lv_material;
      END IF;

    ELSE

      -- Step 3: Check if current material is a SUBSTITUTE
      lt_primaries = SELECT pr.ComponentMaterial AS PrimaryMaterial
                     FROM z_bom_alt_group_view AS alt
                     INNER JOIN z_bom_alt_group_view AS pr
                       ON  pr.Plant            = alt.Plant
                       AND pr.BomNumber        = alt.BomNumber
                       AND pr.AlternativeGroup = alt.AlternativeGroup
                       AND pr.ComponentMaterial != alt.ComponentMaterial
                       AND CAST(pr.Priority AS INTEGER) < CAST(alt.Priority AS INTEGER)
                     WHERE alt.ComponentMaterial = :lv_material
                       AND alt.Plant             = :lv_plant;

      IF EXISTS (SELECT 1 FROM :lt_primaries) THEN

        -- SUBSTITUTE BRANCH: Add demand to reserve this material's stock
        lt_stock_source2 = SELECT CAST(200 AS DECIMAL(31,3)) AS DemandQty
                           FROM dummy
                           WHERE :lv_material="TD_LQ_ROH_B"
                             AND :lv_plant="1320";

        IF EXISTS (SELECT 1 FROM :lt_stock_source2 WHERE DemandQty > 0) THEN

          INSERT INTO :lt_add(MRPElementAvailyOrRqmtDate, MRPElementOpenQuantity,
                              MRPElementCategory, MRPElementType, MRPAvailability)(
            SELECT TO_CHAR(CURRENT_DATE, 'YYYYMMDD'),
                   DemandQty, 'PP', '-', 'X'
            FROM :lt_stock_source2 );

          UPDATE :lt_add SET ProcessingKey          = 'A',
                              MRPPlanningSegmentType="02",
                              MRPPlanningSegment="",
                              MRPPlant               = :lv_plant,
                              MRPArea                = :lv_plant,
                              Material               = :lv_material;
        END IF;
      END IF;

    END IF;

    -- Step 4: Return original rows + any added rows
    ct_supplydemanditemlist =
      SELECT * FROM :ct_supplydemanditemlist__in__
      UNION ALL SELECT * FROM :lt_add;

  ENDMETHOD.

ENDCLASS.

How It Works — Step by Step

  1. Material identification: The BAdI fires once per material in the MRP run. We read the current material from the input table's first row.

  2. Role detection via BOM view: A self-join on Z_BOM_ALT_GROUP_VIEW determines whether the current material has substitutes (it's a primary) or has a primary (it's a substitute). Materials not in any alternative group (like FG02) get clean passthrough.

  3. Primary material — add supply: We calculate MIN(substitute_stock, shortage) and add a positive MRP element. This tells MRP that additional supply is available, reducing the purchase requisition.

  4. Substitute material — add demand: We add a negative MRP element equal to the substitute's stock, reserving it so it doesn't appear as excess inventory.

  5. Passthrough: Original rows are always returned via UNION ALL, preserving all standard MRP data.


Key Breakthroughs and Hard-Won Lessons

This implementation required 14 test iterations and 10 debug versions before reaching a working solution. Below are the most time-consuming problems and their solutions.

1. ProcessingKey — The Silent Killer

Time spent: Multiple test iterations (Tests 1–9) before discovery.

Problem: Every modification to the supply/demand list was silently ignored. No error, no warning — the BAdI appeared to fire correctly, but MD04 showed unchanged data.

Root cause: The BAdI caller only processes rows that have ProcessingKey set:

ProcessingKey Meaning

'A' Added row (new MRP element)
'C' Changed row (modified existing element)
'D' Deleted row (remove existing element)
(empty) Ignored — treated as unchanged

Without ProcessingKey = 'A', your added rows are silently discarded. This is not documented in the BAdI interface definition. We only discovered it after SAP's development team responded to an incident we raised, pointing us to the example class CL_EX_PPH_SUPPLY_DEMAND_LIST.

Lesson: Always set ProcessingKey on every row you add or modify. For adding new supply/demand elements, use 'A'.

Important: In our testing, only ProcessingKey = 'A' (Add) reliably worked. ProcessingKey = 'C' (Change) had no effect when modifying existing BD or BS rows — even when following SAP's documented DELETE + INSERT pattern exactly. Our solution was to add new elements rather than modify existing ones.

2. The INSERT + UPDATE Two-Step Pattern

Problem: In an OPTIONS READ-ONLY AMDP, you cannot use a simple inline SELECT with all columns to construct a new row.

Solution: Use a two-step pattern — first INSERT the data columns, then UPDATE the metadata columns:

 

-- Step 1: INSERT data columns via SELECT from dummy
INSERT INTO :lt_add(MRPElementAvailyOrRqmtDate, MRPElementOpenQuantity,
                    MRPElementCategory, MRPElementType, MRPAvailability)(
  SELECT '20260424', CAST(200 AS DECIMAL(31,3)),
         'PP', '+', 'X'
  FROM dummy );

-- Step 2: UPDATE metadata columns
UPDATE :lt_add SET ProcessingKey          = 'A',
                    MRPPlanningSegmentType = '02',
                    MRPPlanningSegment     = '',
                    MRPPlant               = '1320',
                    MRPArea                = '1320',
                    Material               = 'TD_LQ_ROH_A';

This matches the pattern used in SAP's own example implementation. Do not attempt to set all columns in a single INSERT statement.

3. StorageLocation Is NOT a Column

Problem: Adding StorageLocation to the INSERT column list caused the entire output to be silently empty — no error, no dump, just zero rows returned to the caller.

Root cause: StorageLocation is not a column in the ct_supplydemanditemlist table type. Including it doesn't cause a syntax error, but it corrupts the output in a way that the caller silently discards everything.

Lesson: Only use columns that actually exist in the ct_supplydemanditemlist structure. The key columns that work are:

  • Material, MRPPlant, MRPArea
  • MRPElementAvailyOrRqmtDate, MRPElementOpenQuantity
  • MRPElementCategory, MRPElementType, MRPAvailability
  • ProcessingKey, MRPPlanningSegmentType, MRPPlanningSegment

4. SAP Stock CDS Views Return 0 Rows from AMDP

Time spent: DEBUG 5–7 were entirely dedicated to this problem.

Problem: I_MaterialStock_2 and our custom wrapper Z_UNRESTRICTED_STOCK_VIEW return zero rows when queried from within the AMDP procedure, even with the USING clause properly declared.

Investigation: We created diagnostic versions that counted rows at different filter levels:

Filter Applied Rows Returned

With plant + material filter 0 rows
With plant filter only 0 rows
No filter at all 0 rows
BOM view (I_MaterialBOMLink) Data returned normally

Root cause: I_MaterialStock_2 has @AccessControl.authorizationCheck: #MANDATORY and is an analytical cube view (@analytics: { dataCategory: #CUBE }). These characteristics make it inaccessible from AMDP procedures, likely due to the ABAP Extension Check restrictions in S/4HANA Public Cloud.

Workaround for PoC: Hardcode the substitute stock values. For a production implementation, options include:

  • Read stock quantities in the ABAP layer before the AMDP call, then pass them via an additional importing parameter
  • Use a Table UDF (BY DATABASE FUNCTION) which has looser Extension Check restrictions

5. The BAdI Fires for ALL Materials — Including the Finished Good

Time spent: Test 15 was specifically created to diagnose this.

Problem: When making the logic dynamic, unexpected rows appeared on the finished good (FG02). The BAdI fires not only for raw material components (ROH_A, ROH_B) but also for the parent finished good.

Solution: The dynamic version must handle three cases:

Material Role Detected By Action

Primary (has substitutes) Alt group join: substitutes found Add supply element
Substitute (has a primary) Alt group join: primary found Add demand element
Other (FG02 or not in any alt group) Falls through both IF branches Clean passthrough

The BOM view self-join naturally handles this: materials not found in Z_BOM_ALT_GROUP_VIEW fall through both IF branches and receive pure passthrough — no extra rows added.


6. Scalar SELECT INTO with Multiple Columns Does Not Work

Problem: In OPTIONS READ-ONLY AMDP, a single SELECT returning multiple columns into separate scalar variables fails:

 

-- THIS DOES NOT WORK in OPTIONS READ-ONLY AMDP:
SELECT Material, MRPPlant INTO lv_material, lv_plant
FROM :ct_supplydemanditemlist__in__ LIMIT 1;

Solution: Use separate SELECT statements, one per scalar variable:

 

SELECT Material INTO lv_material FROM :ct_supplydemanditemlist__in__ LIMIT 1;
SELECT MRPPlant INTO lv_plant    FROM :ct_supplydemanditemlist__in__ LIMIT 1;

Dead Ends and Wrong Turns

During the weeks-long investigation, we explored several approaches that ultimately did not work. Documenting them here to save others from the same detours.

Dead End 1: Plain ABAP Instead of AMDP

The interface IF_PPH_SUPPLY_DEMAND_LIST requires the method to be implemented as BY DATABASE PROCEDURE FOR HDB LANGUAGE SQLSCRIPT. A plain ABAP implementation cannot be activated.

Dead End 2: Modifying Existing Rows (ProcessingKey = ‘C')

Despite following SAP's documented DELETE + INSERT pattern with ProcessingKey = 'C', changes to existing BD (dependent requirement) and BS (stock) rows had zero effect across multiple test iterations (Tests 7, 9, 11, 12). The working approach is to add new elements with ProcessingKey = 'A'.

Dead End 3: Custom CDS View for Stock Lookups

Z_UNRESTRICTED_STOCK_VIEW wrapping I_MaterialStock_2 suffered the same zero-rows problem. The underlying view is inaccessible from AMDP regardless of wrapping. We spent significant time on DEBUG 5–7 isolating this, testing different WHERE clauses, removing filters — always 0 rows.

Dead End 4: Single-Step INSERT with All Columns

The ABAP Extension Check in S/4HANA Public Cloud restricts certain DML patterns in OPTIONS READ-ONLY procedures. The two-step pattern (INSERT data columns from a SELECT, then UPDATE metadata columns) is the only reliable approach.

Dead End 5: Z-Table for Stock Snapshots (ZSTOCK_SNAPSHOT)

Idea: Since SAP's analytical stock views return 0 rows from AMDP (see Lesson #4 above), create a physical
transparent Z-table `ZSTOCK_SNAPSHOT` (fields: `CLIENT`, `MATERIAL`, `PLANT`, `STOCK`, `REFRESHED_AT`) as a
pre-computed stock cache. A scheduled ABAP report `ZCL_REFRESH_STOCK_SNAPSHOT` would run before each MRP run — on the
application server, where analytical CDS views *are* accessible — read unrestricted stock from `I_MaterialStock_2`,
and write the results into the Z-table. The AMDP would then read this snapshot at MRP time.

Why it's a dead end for this PoC: The approach is architecturally sound and would work in production. However, it
introduces operational complexity (a scheduled job that must run before every MRP run, table maintenance, staleness
risk) that distracts from the core BAdI pattern this blog post demonstrates. For the proof-of-concept, the stock value
(200 PC for TD_LQ_ROH_B) is hardcoded directly in the SQLScript to keep the focus on the BAdI logic itself.

Why this matters: This is likely the most practical production path. The key insight is that AMDP cannot access
SAP's analytical cube views — so any real stock data must be staged into a physical table beforehand. Understanding
*why* (analytical engine runs on the app server, AMDP runs on the DB layer) saves significant debugging time.


Summary

The BAdI PPH_SUPPLY_DEMAND_LIST provides a viable workaround for the missing MPN functionality in S/4HANA Public Cloud. By adding virtual supply and demand elements during MRP planning, we can effectively tell MRP to consider substitute material stock when calculating purchase requisitions.

The implementation is conceptually simple — the difficulty lies entirely in the AMDP constraints. The key rules to remember:

  1. Always set ProcessingKey = 'A' on added rows — without it, your changes are silently ignored
  2. Use the INSERT + UPDATE two-step pattern — single-step construction fails
  3. Never include StorageLocation in your column lists — it silently kills all output
  4. SAP stock CDS views are inaccessible from AMDP — plan an alternative data source
  5. The BAdI fires for every material in the MRP run, not just components — handle the finished good with clean passthrough
  6. Use separate scalar SELECTs for each variable — multi-column SELECT INTO does not work

For a production scenario, the hardcoded stock values would need to be replaced with a proper stock lookup mechanism — potentially via a Table UDF helper function or by extending the BAdI interface. But the core pattern of adding virtual supply/demand elements to offset BOM substitutes is proven and ready for adaptation.


The features covered in this article are based on SAP S/4HANA Cloud, Public Edition 2602, please refer to the latest information for changes in subsequent versions.

Hope you LIKE it if it addresses your issue. After that, please feel free to comment after following my account and I will reply ASAP.



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *

//
Our customer support team is here to answer your questions. Ask us anything!
👋 Hi, how can I help?