Orchestrate

Report an issue

Something wrong, out of date or unclear? Tell us. Corrections are prioritized over new topics. You don't need to sign in.

0 / 2000

Don't include personal or confidential company details. Reports are reviewed with AI assistance.

Master data that every module depends on

Learn the business partner, material, BOM, routing, work center and purchasing records, who owns each field, and how one wrong value breaks distant processes.

Updated Nov 1, 2026Foundational 9 minDeep 40 min
Foundational layer · 9 min read

The 60-second version

Every business document in SAP points to records that were set up long before the document existed. A sales order names a customer and a product. A purchase order names a supplier and a material. A production order follows a bill of material and a routing. These long-lived records are master data.

Master data is shared. The same material record holds data for purchasing, planning, warehousing, costing and sales, each maintained by a different team. That sharing is what makes SAP integrated, and it is also why one wrong field can break a process several modules away from where it was typed.

Three ideas carry this topic:

  • A few objects do most of the work. The business partner (customers and suppliers), the material, the bill of material, the routing and work center, the purchasing info record and source list, and price condition records.
  • Every field needs an owner. If nobody owns the planned delivery time of a motor, nobody notices when it is wrong, and production runs late.
  • Governance is a process first, a tool second. Agree who creates, changes and approves each kind of record. Then pick the software that enforces it.

Why it matters to the business

Master data errors rarely look like master data errors. They show up as late deliveries, wrong invoices or margins nobody can explain. Four examples from Calder Pumpworks, the fictional pump maker this unit follows. The numbers come from the topic's script and are synthetic:

One wrong field Where it was typed Where it hurts
A motor's planned delivery time is 2 days instead of 21 Purchasing info record Production starts late; order 1000001 ships 19 days late and its cash arrives 19 days later
An overdelivery tolerance is 50% instead of 5% Purchasing info record Ten extra motors are received and paid for, about 9,000 USD, and no invoice exception fires
A customer's payment terms are 90 days instead of 30 Customer sales-area data Invoices fall due two months later; the cash forecast is wrong
A pump's standard price is 28.50 instead of 2,850 Material accounting data Cost of goods sold is understated by over 11,000 USD on one order

The cost is not only the error itself. Teams spend days tracing a symptom back to a field. Leaders who make field ownership explicit shorten that search and prevent many errors outright.

Master data is also the first thing an AI project touches. An assistant that explains blocked orders, or a model that predicts late deliveries, inherits every duplicate and every stale lead time. Clean master data is a precondition for trustworthy AI, not a side project.

How SAP does it

As of October 2026, SAP's approach has three layers:

  • The objects in the ERP. In SAP S/4HANA, customers and suppliers are maintained as business partners. Converting all customer and vendor records to business partners is a prerequisite for converting an SAP ERP (ECC) system to SAP S/4HANA (SAP Learning). Materials, bills of material, routings, work centers, info records, source lists and price records are maintained in the ERP's own apps.
  • Governance tooling. SAP Master Data Governance adds change requests with approval workflows, consolidation of records from several systems with duplicate matching, and data quality rules with scores. As of November 2026, SAP Learning lists it on premise on SAP S/4HANA, in SAP S/4HANA Cloud Private Edition, and in the public cloud as SAP Master Data Governance, cloud edition, and as master data governance in SAP S/4HANA Cloud Public Edition (SAP Learning). It is licensed separately from the ERP; check with SAP for your contract.
  • Released interfaces. Apps and AI tools read master data through released APIs and views, such as API_BUSINESS_PARTNER and I_Product, rather than the database tables (Cloudification Repository).

Who owns what: master data on one page

Object What it records Kept per Usual owner at Calder (fictional)
Business partner, general data Name, address, tax number, bank details Whole system Central master data team
Customer data Payment terms, reconciliation account; sales terms, pricing group Company code; sales area Finance; sales operations
Supplier data Payment terms; order currency, invoice rules Company code; purchasing org Finance; purchasing
Material, basic data Description, base unit, weight, material type Whole system Central master data team
Material, plant data MRP settings, lead times, safety stock Plant Planning (MRP controller)
Material, accounting data Price control, standard price Valuation area Cost accounting
Material, sales data Item category group, minimum order quantity Sales org and channel Sales operations
Bill of material (BOM) Components and quantities of a product Plant Engineering
Routing and work center Operations, times, where work happens, its cost center Plant Production engineering
Purchasing info record A supplier's price, lead time and tolerances for a material Purchasing org (and plant) Purchasing
Source list Which suppliers may supply a material, and when Plant Purchasing
Price condition record A price or discount for a key such as customer and material Sales org and channel, or other keys Pricing team

Questions to ask

  1. Who owns each master data object, and each field group within it? Is that written down?
  2. Which fields, if wrong, would stop production, shipping or billing? Who checks them, and how often?
  3. How are new customers and suppliers requested, checked for duplicates and approved?
  4. If part of the group is still on SAP ECC, is the business partner conversion planned and its data cleansing started?
  5. Where do lead times and prices live: in the material, the info record or a contract? Which one wins?
  6. Do we measure master data quality with a score someone reviews, or only fix errors as they surface?
  7. Which AI use cases depend on this data, and what happens to them if a duplicate or stale value slips through?

Common misconceptions

  • "Master data is IT's job." IT runs the tools. The business owns the meaning: purchasing owns a supplier's lead time, finance owns payment terms.
  • "One team should own the whole material record." The record has views for different teams on purpose. Ownership follows the view, not the record.
  • "A governance tool fixes bad data." It enforces a process. Without agreed owners and rules, it routes bad data more slowly.
  • "The business partner is just a new screen for customers and suppliers." It changes the data model, the number ranges and the interfaces, and it must be done before an ECC conversion.
  • "AI will clean our master data." AI is good at flagging likely duplicates and odd values. A person still decides, because a wrong merge can redirect payments or orders.

Key terms

All course terms, A to Z, in the glossary

  • Master data: long-lived records that many documents refer to, such as customers, suppliers and materials.
  • Business partner (BP): the single object for customers, suppliers and contacts in SAP S/4HANA, with roles for each use.
  • Customer-vendor integration (CVI): the mechanism that keeps business partners and the customer and supplier records in step.
  • Material master: the record for a product or material, with views for each department and org level.
  • Bill of material (BOM): the list of components and quantities that make a product.
  • Routing: the sequence of operations to make a product, each at a work center.
  • Purchasing info record: a supplier's terms for one material, used as defaults in purchase orders.
  • Source list: the allowed sources of supply for a material in a plant, with validity dates.
  • Condition record: a stored price, discount or surcharge with its key and validity.
  • Data steward: the person who reviews, approves and corrects master data for an area.

Check yourself

Pick one answer for each question. The explanation appears after you choose.

1A Calder pump order ships three weeks late. The root cause is a motor lead time typed wrongly in purchasing. What does this show about master data?

Answer: A. Master data is shared across modules, so a purchasing field drives MRP, production, shipping and cash. Named owners and checks catch such errors before they travel.

2Why must a company still on SAP ECC plan business partner conversion early?

Answer: D. SAP Learning lists the conversion of all customer and vendor records to business partners as a prerequisite for moving from SAP ERP to SAP S/4HANA. Cleansing that data takes time, so it starts well before the technical conversion.

3Who should own a material's planned delivery time and safety stock at Calder?

Answer: B. Ownership follows the view and org level. Planning data is kept per plant and used by MRP, so the planner owns it, while basic data and accounting data have other owners.

4A vendor offers a master data governance tool to "fix our data quality". What should you ask first?

Answer: C. Governance is a process first. A tool enforces owners, approvals and rules; without them it only routes bad data more slowly.

5An AI model flags a new supplier request as a likely duplicate. What should happen next?

Answer: A. Flagging likely duplicates is a low-risk use of AI because a person decides. A wrong automatic merge could redirect orders or payments, which is far costlier than a review.

6Which payment terms does a Calder sales order use?

Answer: C. Terms agreed per sales area are copied into sales documents. If they differ from the finance terms, invoices can fall due on a date finance doesn't expect, so both need an owner and a check.
Deep layer · 40 min read

Mental model

Think of master data as the parameters of every process. A process in SAP is a fixed program. The business partner, the material and the other objects in this topic are its inputs. MRP reads a lead time and decides when to order. Purchasing reads an info record and proposes a price. Billing reads payment terms and sets a due date. None of them asks whether the input is sensible.

Two consequences make the rest of the topic click:

  1. Master data sits at the org units from How an SAP enterprise is wired. A material has data per plant, per valuation area and per sales area. A business partner has data per company code, sales area and purchasing organization. The same field can be right in one plant and wrong in another.
  2. Errors travel along document flow. A wrong parameter produces a wrong document, which feeds the next step. The failure shows up where someone finally looks: a shortage, a match exception, a margin report. By then the field that caused it is three modules upstream.

This topic builds on The SAP data model for AI engineers, which covers master versus transactional data, header and item records and CDS views. It doesn't repeat them.

How it works

The business partner and customer-vendor integration

In SAP S/4HANA, the business partner is the entry point for customers, suppliers and their contacts. A business partner has a category, such as person or organization, and one or more roles. As of October 2026, SAP Learning's procurement course shows the pattern for a supplier: general data under role 0000000, company code data under the FI vendor role FLVN00, and purchasing organization data under the supplier role FLVN01. One business partner can be both customer and supplier (SAP Learning).

The classic customer and supplier records still exist underneath. Customer-vendor integration (CVI) keeps them in step: when a business partner is created or changed, the customer and vendor tables are updated automatically. Interfaces that create or change customers and vendors must go through CVI's function CVI_EI_INBOUND_MAIN; IDocs and BAPIs that bypass it can no longer be used for that (same source).

The API mirrors the structure. In API_BUSINESS_PARTNER, A_BusinessPartner holds general data, A_CustomerCompany and A_SupplierCompany hold company code data, A_CustomerSalesArea holds sales-area data, and A_SupplierPurchasingOrg holds purchasing organization data (SAP Cloud SDK package generated from the service metadata, version 2.1.0, npm). The Calder sample pack uses exactly these shapes.

flowchart TB
  BP[Business partner<br/>general data] --> CC[Customer:<br/>company code data]
  BP --> CS[Customer:<br/>sales area data]
  BP --> SC[Supplier:<br/>company code data]
  BP --> SP[Supplier:<br/>purchasing org data]
  CVI[CVI] -.keeps in step.- BP

The material master and who owns each view

The material master holds one record per material, with views for each department. SAP Learning's purchasing course lists them with typical fields (SAP Learning):

View Typical fields Kept per Owner at Calder (fictional)
Basic data Description, base unit, weight Client Master data team
Sales Item category group, minimum order quantity Sales org and channel Sales operations
Purchasing Purchasing group, over- and underdelivery tolerances, order unit Plant Purchasing
MRP MRP type, safety stock, planned delivery time Plant Planning
Accounting Price control, standard or moving price Valuation area Cost accounting
Storage Unit of issue, storage conditions Plant, storage location Warehouse
Forecasting Forecast model and periods Plant Planning

The material type (finished product, semi-finished, raw material, trading good) decides which views and departments apply, how stock is valued and which procurement is allowed (same source).

In API_PRODUCT_SRV, the views appear as separate entities: A_Product for basic data, A_ProductPlant and A_ProductSupplyPlanning for plant and MRP data, A_ProductPlantProcurement (with IsSourceListRequired), A_ProductWorkScheduling, A_ProductValuation (with StandardPrice and InventoryValuationProcedure) and A_ProductSalesDelivery (with ItemCategoryGroup) (SAP Cloud SDK, version 2.1.0, npm). Each entity is a natural unit of ownership.

Production master data: BOM, routing, work center

Making a product needs three more objects, all kept per plant (SAP Learning):

  • Bill of material (BOM): the components and quantities for a product. Assemblies have their own BOMs, so structures are multi-level.
  • Routing: the operations to make the product, such as assembly and testing.
  • Work center: where an operation runs. Its data drives capacity planning, scheduling and costing. In API_WORK_CENTER, the WorkCenterCostCenter entity links it to a cost center and activity type.
  • Production version: one BOM plus one routing. Each production order is created against a production version.

Released APIs exist for each: API_BILL_OF_MATERIAL_SRV, API_PRODUCTION_ROUTING and the OData V4 work center service api_work_center (SAP Cloud SDK, version 2.1.0, npm).

Procurement master data: info records and source lists

A purchasing info record holds the relationship between a material (or material group) and a supplier: prices and conditions, the supplier's material number, the planned delivery time, and over- and underdelivery tolerance limits. Conditions are kept per purchasing organization, or per purchasing organization and plant. Purchase orders take the info record's data as defaults (SAP Learning).

A source list says which sources may supply a material in a plant during a validity period. One source can be marked fixed (the default except in the planning run) and others blocked. If a plant requires source lists, a material without a valid entry can't be ordered (SAP Learning). The product API also carries a per-plant IsSourceListRequired flag.

The two objects interact with the material master in a way that surprises people. The material's MRP view holds a planned delivery time, and so does the info record. When MRP determines a source such as an info record, the source's planned delivery time and goods receipt processing time take priority over the material master's (SAP Learning). A planner who checks only the material view can miss the value that MRP actually uses.

Sales master data: price condition records

Sales prices are master data too. A condition record stores a price, discount or surcharge for a condition type (SAP Learning's Public Edition course uses PPR0 for the base price), a key combination such as customer and material, a validity period and optional scales. Business users maintain them in the Manage Prices - Sales app (SAP Learning). The API is API_SLSPRICINGCONDITIONRECORD_SRV, with A_SlsPrcgConditionRecord and its validity entity.

Payment terms deserve a special mention because they exist twice for a customer: in company code data (finance) and in sales-area data (sales). Terms agreed per sales area are copied into sales documents, and they may deviate from the finance terms (SAP Help Portal, an older release page whose principle still applies; check your system's configuration). Two owners, one customer, two values: a classic source of disputes.

How one wrong field travels

flowchart LR
  IR[Info record<br/>lead time 2 days] --> MRP[MRP orders<br/>too late]
  MRP --> PO[Motors<br/>arrive late]
  PO --> PP[Assembly<br/>slips]
  PP --> SD[Delivery<br/>late]
  SD --> FI[Invoice and<br/>cash late]

The pattern repeats for every object in this topic:

Field Object and level First consumer Where the failure shows up
Planned delivery time Info record or material, plant MRP Shortage in production, late customer delivery
Overdelivery tolerance Info record or material, plant Goods receipt Excess stock; the invoice matches, so no exception
Customer payment terms Customer, sales area Sales order, billing Late cash, cash forecast off
Standard price Material, valuation area Goods issue posting Wrong cost of goods sold and margins
BOM quantity BOM, plant MRP, production staging Wrong components staged, wrong product cost
Work center cost center Work center, plant Confirmations Costs on the wrong cost center

The tolerance row is worth a second look. With goods-receipt-based invoice verification, the invoice is checked against what was received. If the tolerance let too much in, the invoice matches the receipt and passes. The three-way match exceptions this unit returns to in procure to pay only catch errors downstream of the receipt.

Governance: a process, then a tool

Master data governance answers four questions for each object: who may request a record, who checks it, who approves it, and who may change which fields afterwards. A minimal process looks like this:

flowchart LR
  R[Request] --> D[Duplicate and<br/>completeness check]
  D --> E[Enrich by<br/>each owner]
  E --> A[Approve]
  A --> C[Create in ERP<br/>and distribute]
  C --> M[Monitor quality<br/>score]

SAP Master Data Governance implements this as central governance: change requests with workflows, approval, activation and distribution, for business partners (including customers and suppliers), materials, financial objects and custom objects. Its consolidation loads records from several sources, standardizes them, finds duplicates and computes a best record by survivorship rules. Data quality management defines validation and derivation rules and tracks a quality score (SAP Learning). The tool earns its licence when the owners and rules already exist on paper.

Where AI helps, and where it doesn't

Three master data tasks are low-risk wins for AI, because the output is a suggestion a person reviews:

  • Duplicate detection. Fuzzy name matching catches "Orrin Motorworks, Inc." against "Orrin Motor Works". Embeddings go further and catch descriptions that share meaning but no words; Semantic search over SAP master data shows how.
  • Completeness checks. Rules find a bought material with no source list, or a made product with no routing, before a process fails.
  • Anomaly checks. A purchase price 37% above the standard price, or a standard price below the cost of its components, deserves a look. Anomaly detection in finance and procurement covers statistical methods.

Where AI should not act alone: merging records, changing bank details, or changing any field that moves money or goods. Those stay with an approver.

Build it yourself

Before you start: finish Set up your computer for this course and Set up for Unit 1B. This walkthrough uses the Calder sample pack from the Unit 1B setup.

You will write one script that adds the rest of Calder's master data (BOMs, routings, work centers, info records, a source list, price records) to the sample pack in memory, and then does four things. It checks completeness and flags anomalies. It breaks one field on purpose and traces the damage across modules. It screens new supplier requests for duplicates. And it writes the field-ownership matrix, the deliverable of this topic.

flowchart LR
  P[calder_pack/<br/>partners, products,<br/>order 1000001] --> S[master_data.py]
  X[Extra master data<br/>in the script] --> S
  S --> C[Checks:<br/>ERROR / WARNING]
  S --> B[--break:<br/>ripple trace]
  S --> D[--duplicates:<br/>steward queue]
  S --> M[--matrix:<br/>ownership table]

What you need

  • The course folder orchestrate-course with its .venv, and the Unit 1B sample pack.
  • No account, no key and no network. The script uses only Python's built-in modules, so requirements.txt doesn't change.
  • Cost: free. Time: about 40 minutes.

Step 1: Open your course folder and turn on the virtual environment

  1. Open VS Code, then File > Open Folder and choose orchestrate-course.
  2. Open a terminal with Terminal > New Terminal.
  3. Turn on the virtual environment:
    • Windows (PowerShell):

      .venv\Scripts\Activate.ps1
    • macOS / Linux:

      source .venv/bin/activate

You should see (.venv) at the start of the prompt.

Step 2: Make sure the sample pack is current

  1. Rewrite the pack, so you start from the same data as this topic:

    • Windows (PowerShell):

      python unit1b\make_calder_pack.py
    • macOS / Linux:

      python unit1b/make_calder_pack.py
  2. The last lines should read:

Calder sample pack 1.0: order 1000001, net 29,100.00 USD, cost 19,600.00 USD.
All data is fictional. Next: python check_unit1b.py

Step 3: Create the script

  1. In VS Code, create a new file, paste the script below, and save it as master_data.py inside the unit1b folder.
  2. Read the top part, from PLANT_DATA to FIELD_OWNERSHIP, before running anything. It is Calder's master data written as Python lists, in the same field names as SAP's APIs. A functional consultant can read it like a set of configuration tables.
"""Check Calder's master data, trace what one wrong field breaks, and find duplicates.

Calder Pumpworks is a fictional company. The pack (unit1b/calder_pack/) and the
extra master data below are made up for the course. Field names follow SAP's
released APIs where one exists; codes and values are illustrative, not SAP standard.

Run it from your course folder:
  python unit1b/master_data.py                          run the completeness and anomaly checks
  python unit1b/master_data.py --break planned-delivery  change one field and trace the ripple
  python unit1b/master_data.py --duplicates              screen new supplier requests for duplicates
  python unit1b/master_data.py --matrix                  write unit1b/calder_field_ownership.md
Uses only Python's built-in modules. Needs no account and no network.
"""
import argparse
import copy
import json
import re
import sys
from datetime import date, timedelta
from difflib import SequenceMatcher
from pathlib import Path

HERE = Path(__file__).resolve().parent
PACK = HERE / "calder_pack"
AS_OF = "2026-10-23"  # fixed date, so every run gives the same result

# ------------------------------------------------- extra master data (fictional)
# Plant-level planning data, shaped after A_ProductSupplyPlanning. MakeOrBuy is a
# course field: SAP stores this as a procurement type key, which varies by setup.
PLANT_DATA = [
    {"Product": "CP-ES200", "Plant": "CP11", "MakeOrBuy": "make", "InHouseProductionTime": 5,
     "SafetyStockQuantity": 4, "GoodsReceiptDuration": 1, "PlannedDeliveryDurationInDays": 0},
    {"Product": "CP-ES300", "Plant": "CP11", "MakeOrBuy": "make", "InHouseProductionTime": 6,
     "SafetyStockQuantity": 2, "GoodsReceiptDuration": 1, "PlannedDeliveryDurationInDays": 0},
    {"Product": "CP-MTR15", "Plant": "CP11", "MakeOrBuy": "buy", "IsSourceListRequired": True,
     "SafetyStockQuantity": 6, "GoodsReceiptDuration": 1, "PlannedDeliveryDurationInDays": 21},
    {"Product": "CP-IMP200", "Plant": "CP11", "MakeOrBuy": "buy", "IsSourceListRequired": True,
     "SafetyStockQuantity": 8, "GoodsReceiptDuration": 2, "PlannedDeliveryDurationInDays": 28},
    {"Product": "CP-IMP200", "Plant": "CP21", "MakeOrBuy": "make", "InHouseProductionTime": 4,
     "SafetyStockQuantity": 20, "GoodsReceiptDuration": 1, "PlannedDeliveryDurationInDays": 0},
    {"Product": "CP-SK200", "Plant": "CP11", "MakeOrBuy": "buy",
     "SafetyStockQuantity": 50, "GoodsReceiptDuration": 1, "PlannedDeliveryDurationInDays": 10},
]
# Rockford buys the Augsburg impeller from CP20, so it needs its own valuation in CP11.
EXTRA_VALUATION = [{"Product": "CP-IMP200", "ValuationArea": "CP11", "StandardPrice": 335.00,
                    "PriceUnitQty": 1, "Currency": "USD"}]
# Bills of material, shaped after A_BillOfMaterial / A_BillOfMaterialItem (trimmed
# to components the pack already has).
BOMS = [
    {"BillOfMaterial": "00000001", "Material": "CP-ES200", "Plant": "CP11", "Items": [
        {"BillOfMaterialItemNumber": "0010", "BillOfMaterialComponent": "CP-IMP200", "BillOfMaterialItemQuantity": 1},
        {"BillOfMaterialItemNumber": "0020", "BillOfMaterialComponent": "CP-MTR15", "BillOfMaterialItemQuantity": 1},
        {"BillOfMaterialItemNumber": "0030", "BillOfMaterialComponent": "CP-SK200", "BillOfMaterialItemQuantity": 1}]},
    {"BillOfMaterial": "00000002", "Material": "CP-ES300", "Plant": "CP11", "Items": [
        {"BillOfMaterialItemNumber": "0010", "BillOfMaterialComponent": "CP-MTR15", "BillOfMaterialItemQuantity": 1}]},
]
# Routings, after ProductionRoutingOperation. The API links an operation to a work
# center by internal ID; this course shape uses the work center code instead.
ROUTINGS = [
    {"ProductionRoutingGroup": "50000001", "Material": "CP-ES200", "Plant": "CP11", "Operations": [
        {"Operation": "0010", "OperationText": "Assemble pump", "WorkCenter": "ASSY1", "StandardWorkQuantity1": 1.5},
        {"Operation": "0020", "OperationText": "Test on bench", "WorkCenter": "TEST1", "StandardWorkQuantity1": 0.5}]},
    {"ProductionRoutingGroup": "50000002", "Material": "CP-ES300", "Plant": "CP11", "Operations": [
        {"Operation": "0010", "OperationText": "Assemble pump", "WorkCenter": "ASSY1", "StandardWorkQuantity1": 2.0},
        {"Operation": "0020", "OperationText": "Test on bench", "WorkCenter": "TEST1", "StandardWorkQuantity1": 0.75}]},
]
# Work centers, after WorkCenterHeader and WorkCenterCostCenter.
WORK_CENTERS = [
    {"WorkCenter": "ASSY1", "Plant": "CP11", "WorkCenterDesc": "Pump assembly line", "CostCenter": "CP11-410"},
    {"WorkCenter": "TEST1", "Plant": "CP11", "WorkCenterDesc": "Pump test bench", "CostCenter": "CP11-420"},
]
# Purchasing info records, after A_PurgInfoRecdOrgPlantData.
INFO_RECORDS = [
    {"PurchasingInfoRecord": "5300000001", "Supplier": "20000001", "Material": "CP-MTR15",
     "PurchasingOrganization": "PUUS", "Plant": "CP11", "NetPriceAmount": 905.00, "Currency": "USD",
     "MaterialPlannedDeliveryDurn": 21, "OverdelivTolrtdLmtRatioInPct": 5.0, "UnderdelivTolrtdLmtRatioInPct": 5.0},
    {"PurchasingInfoRecord": "5300000002", "Supplier": "20000010", "Material": "CP-IMP200",
     "PurchasingOrganization": "PUUS", "Plant": "CP11", "NetPriceAmount": 335.00, "Currency": "USD",
     "MaterialPlannedDeliveryDurn": 28, "OverdelivTolrtdLmtRatioInPct": 0.0, "UnderdelivTolrtdLmtRatioInPct": 0.0},
    {"PurchasingInfoRecord": "5300000003", "Supplier": "20000003", "Material": "CP-SK200",
     "PurchasingOrganization": "PUUS", "Plant": "CP11", "NetPriceAmount": 52.00, "Currency": "USD",
     "MaterialPlannedDeliveryDurn": 10, "OverdelivTolrtdLmtRatioInPct": 10.0, "UnderdelivTolrtdLmtRatioInPct": 10.0},
]
# Source list, after A_PurchasingSource.
SOURCE_LIST = [
    {"Material": "CP-MTR15", "Plant": "CP11", "SourceListRecord": "1", "Supplier": "20000001",
     "PurchasingOrganization": "PUUS", "ValidityStartDate": "2026-01-01", "ValidityEndDate": "2026-12-31",
     "SupplierIsFixed": True},
    {"Material": "CP-IMP200", "Plant": "CP11", "SourceListRecord": "1", "Supplier": "20000010",
     "PurchasingOrganization": "PUUS", "ValidityStartDate": "2026-01-01", "ValidityEndDate": "2027-12-31",
     "SupplierIsFixed": True},
]
# Sales price condition records, after A_SlsPrcgConditionRecord and its validity.
PRICES = [
    {"ConditionType": "PPR0", "Material": "CP-ES200", "SalesOrganization": "CPUS", "DistributionChannel": "10",
     "ConditionRateValue": 4200.00, "Currency": "USD",
     "ConditionValidityStartDate": "2026-01-01", "ConditionValidityEndDate": "2026-12-31"},
    {"ConditionType": "PPR0", "Material": "CP-ES300", "SalesOrganization": "CPUS", "DistributionChannel": "10",
     "ConditionRateValue": 6150.00, "Currency": "USD",
     "ConditionValidityStartDate": "2026-01-01", "ConditionValidityEndDate": "2026-12-31"},
]
# Calder's illustrative payment terms keys and policy limits (not SAP standard).
TERMS_DAYS = {"NT30": 30, "NT45": 45, "NT60": 60, "NT90": 90}
MAX_OVERDELIVERY_PCT = 10.0
PRICE_DEVIATION_PCT = 25.0
# Extra business partner data for duplicate screening: country and a made-up tax number.
BP_EXTRA = {
    "10000001": ("US", "FX-1001"), "10000002": ("US", "FX-1002"), "10000010": ("DE", "FX-9010"),
    "20000001": ("US", "FX-2001"), "20000002": ("DE", "FX-2002"), "20000003": ("US", "FX-2003"),
    "20000010": ("DE", "FX-9020"),
}
# New supplier requests waiting for approval (fictional).
REQUESTS = [
    {"Request": "R-0042", "Name": "Orrin Motorworks, Inc.", "Country": "US", "BPTaxNumber": "FX-2001"},
    {"Request": "R-0043", "Name": "Ridgeline Bearing Supply Co.", "Country": "US", "BPTaxNumber": ""},
    {"Request": "R-0044", "Name": "Halden Valve Company", "Country": "US", "BPTaxNumber": "FX-3001"},
    {"Request": "R-0045", "Name": "Tamsfield Foundry", "Country": "US", "BPTaxNumber": "FX-3002"},
]

# Who owns each field, who uses it, and what breaks. This is the deliverable.
FIELD_OWNERSHIP = [
    ("Business partner", "Client", "A_BusinessPartner.OrganizationBPName1", "Master data team",
     "Every module", "Duplicates and wrong names on orders, invoices and payments"),
    ("Business partner", "Client", "A_BusinessPartnerTaxNumber.BPTaxNumber", "Master data team",
     "Tax, duplicate checks", "Wrong tax on invoices; duplicates slip through"),
    ("Business partner", "Client", "A_BusinessPartnerBank.IBAN", "Accounts payable (with approval)",
     "Payment run", "Payments go to the wrong account"),
    ("Customer", "Company code", "A_CustomerCompany.ReconciliationAccount", "Finance (accounts receivable)",
     "Billing, finance", "Receivables post to the wrong balance sheet account"),
    ("Customer", "Company code", "A_CustomerCompany.PaymentTerms", "Finance (accounts receivable)",
     "Finance, dunning", "Due dates and dunning disagree with the contract"),
    ("Customer", "Sales area", "A_CustomerSalesArea.CustomerPaymentTerms", "Sales operations",
     "Sales orders, billing, cash forecast", "Invoices fall due late; cash arrives late"),
    ("Supplier", "Company code", "A_SupplierCompany.PaymentTerms", "Finance (accounts payable)",
     "Payment run", "Suppliers are paid early or late"),
    ("Supplier", "Purchasing org", "A_SupplierPurchasingOrg.InvoiceIsGoodsReceiptBased", "Purchasing",
     "Invoice verification", "Invoices can't be matched, or are matched without a receipt"),
    ("Product", "Client", "A_Product.BaseUnit", "Master data team",
     "Every module", "Quantities convert wrongly everywhere"),
    ("Product", "Plant", "A_ProductSupplyPlanning.PlannedDeliveryDurationInDays", "Planning (MRP controller)",
     "MRP, purchasing", "Orders placed too late; shortages in production"),
    ("Product", "Plant", "A_ProductSupplyPlanning.SafetyStockQuantity", "Planning (MRP controller)",
     "MRP, inventory", "Stock-outs or excess inventory"),
    ("Product", "Plant", "A_ProductPlantProcurement.IsSourceListRequired", "Purchasing",
     "Purchasing, MRP", "Items can't be ordered, or are ordered from anyone"),
    ("Product", "Valuation area", "A_ProductValuation.StandardPrice", "Cost accounting",
     "Inventory postings, margins", "Cost of goods sold and margins are wrong"),
    ("Product", "Sales org and channel", "A_ProductSalesDelivery.ItemCategoryGroup", "Sales operations",
     "Sales orders, billing", "Wrong item category; delivery or billing doesn't happen"),
    ("Info record", "Purchasing org and plant", "A_PurgInfoRecdOrgPlantData.MaterialPlannedDeliveryDurn",
     "Purchasing", "MRP scheduling", "Overrides the material's lead time, often silently"),
    ("Info record", "Purchasing org and plant", "A_PurgInfoRecdOrgPlantData.OverdelivTolrtdLmtRatioInPct",
     "Purchasing", "Goods receipt, invoice match", "Excess goods accepted and paid for"),
    ("Source list", "Plant", "A_PurchasingSource.SupplierIsFixed", "Purchasing",
     "Purchasing, MRP", "Orders go to the wrong supplier"),
    ("Bill of material", "Plant", "A_BillOfMaterialItem.BillOfMaterialItemQuantity", "Engineering",
     "MRP, production, costing", "Wrong components staged; wrong product cost"),
    ("Routing", "Plant", "ProductionRoutingOperation.StandardWorkQuantity1", "Production engineering",
     "Scheduling, capacity, costing", "Wrong lead times and product cost"),
    ("Work center", "Plant", "WorkCenterCostCenter.CostCenter", "Production engineering with controlling",
     "Confirmations, costing", "Production costs land on the wrong cost center"),
    ("Price condition", "Sales org and channel", "A_SlsPrcgConditionRecord.ConditionRateValue", "Pricing team",
     "Sales orders, billing", "Orders priced wrongly; revenue leakage or disputes"),
]


def load_pack() -> dict:
    """Read the setup topic's pack files."""
    data = {}
    for name in ("business_partners.json", "products.json", "order_1000001.json"):
        path = PACK / name
        if not path.exists():
            sys.exit(f"Can't find {path}. Run: python unit1b/make_calder_pack.py")
        data[name.split(".")[0]] = json.loads(path.read_text(encoding="utf-8"))
    return data


def build_model(pack: dict) -> dict:
    """Combine the pack with the extra master data into one model."""
    m = {name: copy.deepcopy(value) for name, value in {
        "plant": PLANT_DATA, "boms": BOMS, "routings": ROUTINGS, "work_centers": WORK_CENTERS,
        "info_records": INFO_RECORDS, "source_list": SOURCE_LIST, "prices": PRICES}.items()}
    m["bps"] = copy.deepcopy(pack["business_partners"]["BusinessPartners"])
    m["products"] = copy.deepcopy(pack["products"]["Products"])
    m["order"] = pack["order_1000001"]
    m["valuation"] = [v for p in m["products"] for v in p["to_Valuation"]] + copy.deepcopy(EXTRA_VALUATION)
    return m


def find(rows, **keys):
    """Return the first row whose fields match all keys, or None."""
    return next((r for r in rows if all(r.get(k) == v for k, v in keys.items())), None)


def check(m: dict, on: str):
    """Completeness and consistency rules (errors) and anomaly rules (warnings)."""
    errors, warnings = [], []
    plant_keys = {(p["Product"], p["Plant"]) for p in m["plant"]}
    for bom in m["boms"]:
        for item in bom["Items"]:
            if (item["BillOfMaterialComponent"], bom["Plant"]) not in plant_keys:
                errors.append(f"BOM {bom['Material']}: component {item['BillOfMaterialComponent']} "
                              f"has no plant data in {bom['Plant']}")
    for p in m["plant"]:
        key = f"{p['Product']} in {p['Plant']}"
        if not find(m["valuation"], Product=p["Product"], ValuationArea=p["Plant"]):
            errors.append(f"{key}: no valuation (standard price) for this plant")
        if p["MakeOrBuy"] == "make" and p["Plant"] == "CP11":
            if not find(m["boms"], Material=p["Product"], Plant=p["Plant"]):
                errors.append(f"{key}: made in-house but has no bill of material")
            if not find(m["routings"], Material=p["Product"], Plant=p["Plant"]):
                errors.append(f"{key}: made in-house but has no routing")
        if p["MakeOrBuy"] == "buy":
            info = find(m["info_records"], Material=p["Product"], Plant=p["Plant"])
            if not info:
                warnings.append(f"{key}: bought, but no info record; buyers must key prices by hand")
            elif info["MaterialPlannedDeliveryDurn"] != p["PlannedDeliveryDurationInDays"]:
                warnings.append(f"{key}: info record {info['PurchasingInfoRecord']} says "
                                f"{info['MaterialPlannedDeliveryDurn']} days, material says "
                                f"{p['PlannedDeliveryDurationInDays']}; MRP gives the info record priority")
            if p.get("IsSourceListRequired") and not any(
                    s["Material"] == p["Product"] and s["Plant"] == p["Plant"]
                    and s["ValidityStartDate"] <= on <= s["ValidityEndDate"] for s in m["source_list"]):
                errors.append(f"{key}: source list required, but no entry is valid on {on}")
    for r in m["routings"]:
        for op in r["Operations"]:
            wc = find(m["work_centers"], WorkCenter=op["WorkCenter"], Plant=r["Plant"])
            if not wc:
                errors.append(f"routing {r['Material']} op {op['Operation']}: work center "
                              f"{op['WorkCenter']} doesn't exist in {r['Plant']}")
            elif not wc.get("CostCenter"):
                errors.append(f"work center {wc['WorkCenter']}: no cost center")
    for bp in m["bps"]:
        for comp in bp.get("to_Supplier", {}).get("to_SupplierCompany", []):
            if comp.get("PaymentTerms") not in TERMS_DAYS:
                errors.append(f"supplier {bp['BusinessPartner']} in {comp['CompanyCode']}: "
                              f"unknown payment terms {comp.get('PaymentTerms')!r}")
        cust = bp.get("to_Customer", {})
        company_terms = {c["PaymentTerms"] for c in cust.get("to_CustomerCompany", [])}
        for area in cust.get("to_CustomerSalesArea", []):
            terms = area.get("CustomerPaymentTerms")
            label = f"customer {bp['BusinessPartner']} in {area['SalesOrganization']}/{area['DistributionChannel']}"
            if terms not in TERMS_DAYS:
                errors.append(f"{label}: unknown payment terms {terms!r}")
            elif terms not in company_terms:
                warnings.append(f"{label}: sales-area terms {terms} differ from company code terms "
                                f"{', '.join(sorted(company_terms))}; sales documents copy the sales-area terms")
    for info in m["info_records"]:
        std = find(m["valuation"], Product=info["Material"], ValuationArea=info["Plant"])
        if std and std["StandardPrice"]:
            dev = 100 * (info["NetPriceAmount"] - std["StandardPrice"]) / std["StandardPrice"]
            if abs(dev) > PRICE_DEVIATION_PCT:
                warnings.append(f"info record {info['PurchasingInfoRecord']} ({info['Material']}): price "
                                f"{info['NetPriceAmount']:.2f} is {dev:+.0f}% from standard price "
                                f"{std['StandardPrice']:.2f}")
        if info["OverdelivTolrtdLmtRatioInPct"] > MAX_OVERDELIVERY_PCT:
            warnings.append(f"info record {info['PurchasingInfoRecord']} ({info['Material']}): overdelivery "
                            f"tolerance {info['OverdelivTolrtdLmtRatioInPct']:.0f}% exceeds Calder's "
                            f"{MAX_OVERDELIVERY_PCT:.0f}% policy")
    for bom in m["boms"]:
        own = find(m["valuation"], Product=bom["Material"], ValuationArea=bom["Plant"])
        parts = sum(i["BillOfMaterialItemQuantity"] * (find(m["valuation"], Product=i["BillOfMaterialComponent"],
                    ValuationArea=bom["Plant"]) or {"StandardPrice": 0})["StandardPrice"] for i in bom["Items"])
        if own and own["StandardPrice"] < parts:
            warnings.append(f"{bom['Material']}: standard price {own['StandardPrice']:,.2f} is below its "
                            f"components' {parts:,.2f}; check the cost estimate")
    so = m["order"]["SalesOrder"]
    for item in so["to_Item"]:
        price = next((p for p in m["prices"] if p["Material"] == item["Material"]
                      and p["SalesOrganization"] == so["SalesOrganization"]
                      and p["DistributionChannel"] == so["DistributionChannel"]
                      and p["ConditionValidityStartDate"] <= so["SalesOrderDate"] <= p["ConditionValidityEndDate"]), None)
        expected = round(price["ConditionRateValue"] * item["RequestedQuantity"], 2) if price else None
        if expected != item["NetAmount"]:
            errors.append(f"order {so['SalesOrder']} item {item['SalesOrderItem']}: net {item['NetAmount']:.2f} "
                          f"but price records give {expected}")
    return errors, warnings


def days(d: str, n: int) -> str:
    """Add n calendar days to an ISO date (negative n goes back)."""
    return (date.fromisoformat(d) + timedelta(days=n)).isoformat()


def ripple(name: str, m: dict) -> list:
    """Apply one wrong field and return the trace of what it breaks downstream (synthetic)."""
    so = m["order"]["SalesOrder"]
    bill = m["order"]["BillingDocument"]
    net = so["TotalNetAmount"]
    if name == "planned-delivery":
        info = find(m["info_records"], PurchasingInfoRecord="5300000001")
        info["MaterialPlannedDeliveryDurn"] = 2  # someone typed 2 instead of 21
        es = find(m["plant"], Product="CP-ES200", Plant="CP11")
        need = days(so["RequestedDeliveryDate"], -es["InHouseProductionTime"])
        planned_order = days(need, -1 - 2)
        real_arrival = days(planned_order, 21 + 1)
        late = (date.fromisoformat(real_arrival) - date.fromisoformat(need)).days
        return [
            ("Purchasing master data", f"Info record 5300000001: planned delivery time 21 -> 2 days"),
            ("MRP", f"Motors CP-MTR15 needed {need}; order placed {planned_order} instead of {days(need, -22)}"),
            ("Supplier", f"Orrin still needs 21 days: motors usable {real_arrival}, {late} days late"),
            ("Production", f"Pump assembly for order {so['SalesOrder']} slips {late} days"),
            ("Sales", f"Delivery promised {so['RequestedDeliveryDate']} now {days(so['RequestedDeliveryDate'], late)}"),
            ("Finance", f"Invoice and cash of {net:,.2f} USD arrive {late} days later"),
        ]
    if name == "tolerance":
        info = find(m["info_records"], PurchasingInfoRecord="5300000001")
        info["OverdelivTolrtdLmtRatioInPct"] = 50.0  # 5 became 50
        ordered, extra = 20, 10
        return [
            ("Purchasing master data", "Info record 5300000001: overdelivery tolerance 5% -> 50%"),
            ("Purchase order", f"New order for {ordered} motors inherits the 50% tolerance"),
            ("Goods receipt", f"Orrin ships {ordered + extra}; the receipt accepts all of them"),
            ("Inventory", f"{extra} unneeded motors in stock: {extra * info['NetPriceAmount']:,.2f} USD"),
            ("Invoice verification", "Invoice matches the goods receipt, so no match exception is raised"),
            ("Finance", f"Calder pays {extra * info['NetPriceAmount']:,.2f} USD for stock it didn't plan"),
        ]
    if name == "payment-terms":
        area = m["bps"][0]["to_Customer"]["to_CustomerSalesArea"][0]
        area["CustomerPaymentTerms"] = "NT90"  # chosen from the wrong row of a list
        due_ok = days(bill["BillingDocumentDate"], TERMS_DAYS["NT30"])
        due_bad = days(bill["BillingDocumentDate"], TERMS_DAYS["NT90"])
        return [
            ("Sales master data", "Customer 10000001, sales area CPUS/10/PU: terms NT30 -> NT90"),
            ("Sales order", "Next orders copy NT90 from the sales area"),
            ("Billing", f"Invoice dated {bill['BillingDocumentDate']} falls due {due_bad}, not {due_ok}"),
            ("Accounts receivable", "Dunning waits 60 extra days; the customer pays later, legitimately"),
            ("Cash forecast", f"{net:,.2f} USD per order like 1000001 moves out by 60 days"),
        ]
    if name == "standard-price":
        val = find(m["valuation"], Product="CP-ES200", ValuationArea="CP11")
        val["StandardPrice"] = 28.50  # decimal point in the wrong place
        qty = next(i["RequestedQuantity"] for i in so["to_Item"] if i["Material"] == "CP-ES200")
        cost_ok, cost_bad = qty * 2850.00, qty * 28.50
        return [
            ("Costing master data", "CP-ES200 in CP11: standard price 2,850.00 -> 28.50 USD"),
            ("Goods issue", f"Delivering {qty} pumps posts cost of {cost_bad:,.2f} instead of {cost_ok:,.2f} USD"),
            ("Finance", f"Cost of goods sold understated by {cost_ok - cost_bad:,.2f} USD on one order"),
            ("Reporting", "The order's margin looks far better than it is until someone revalues the stock"),
        ]
    raise ValueError(name)


def normalize(name: str) -> str:
    """Lower-case a company name and drop punctuation and legal-form words."""
    words = re.sub(r"[^a-z0-9 ]", " ", name.lower()).split()
    return " ".join(w for w in words if w not in {"inc", "co", "company", "gmbh", "ltd", "llc", "the"})


def screen_duplicates(m: dict, threshold: float = 0.85) -> list:
    """Score each new supplier request against existing business partners."""
    results = []
    for req in REQUESTS:
        best = (0.0, None, "")
        for bp in m["bps"]:
            country, tax = BP_EXTRA[bp["BusinessPartner"]]
            if req["BPTaxNumber"] and req["BPTaxNumber"] == tax:
                best = max(best, (1.0, bp, "same tax number"), key=lambda b: b[0])
                continue
            score = SequenceMatcher(None, normalize(req["Name"]), normalize(bp["BusinessPartnerFullName"])).ratio()
            reason = "similar name" if country == req["Country"] else "similar name, other country"
            if country != req["Country"]:
                score *= 0.9  # same name in another country is weaker evidence
            best = max(best, (score, bp, reason), key=lambda b: b[0])
        score, bp, reason = best
        verdict = "LIKELY DUPLICATE" if score >= threshold else "new"
        results.append((req, round(score, 2), bp, reason, verdict))
    return results


def write_matrix() -> Path:
    """Write the field-ownership matrix as a Markdown table."""
    out = HERE / "calder_field_ownership.md"
    lines = ["# Calder Pumpworks: master data field ownership", "",
             "Fictional company. API names from SAP's released services; owners are Calder's design.", "",
             "| Object | Org level | Field (API entity.field) | Owner | Used by | If wrong |",
             "| --- | --- | --- | --- | --- | --- |"]
    lines += [f"| {o} | {lvl} | `{f}` | {own} | {use} | {bad} |" for o, lvl, f, own, use, bad in FIELD_OWNERSHIP]
    out.write_text("\n".join(lines) + "\n", encoding="utf-8")
    return out


def main() -> None:
    parser = argparse.ArgumentParser(description="Calder master data checks (fictional data).")
    parser.add_argument("--break", dest="scenario",
                        choices=["planned-delivery", "tolerance", "payment-terms", "standard-price"],
                        help="change one field and trace what it breaks")
    parser.add_argument("--duplicates", action="store_true", help="screen new supplier requests")
    parser.add_argument("--matrix", action="store_true", help="write the field-ownership matrix")
    parser.add_argument("--on", default=AS_OF, help="date for validity checks (YYYY-MM-DD)")
    args = parser.parse_args()
    model = build_model(load_pack())

    if args.matrix:
        out = write_matrix()
        print(f"Wrote {out.relative_to(HERE.parent)} with {len(FIELD_OWNERSHIP)} fields.")
        return
    if args.duplicates:
        print("New supplier requests vs. existing business partners (fictional data):\n")
        for req, score, bp, reason, verdict in screen_duplicates(model):
            match = f"{bp['BusinessPartner']} {bp['BusinessPartnerFullName']}"
            if verdict == "new":
                match = "closest: " + match
            print(f"  {req['Request']}  {req['Name']:<30} {score:4.2f}  {verdict:<16} {match} ({reason})")
        print("\nLikely duplicates go to a data steward. Nothing is created or merged automatically.")
        return

    trace = ripple(args.scenario, model) if args.scenario else []
    if trace:
        print(f"Scenario '{args.scenario}': one wrong field, traced across modules (synthetic):\n")
        for step, (where, what) in enumerate(trace, start=1):
            print(f"  {step}. {where:<23} {what}")
        print()
    errors, warnings = check(model, args.on)
    print(f"Master data checks on {args.on}: {len(model['plant'])} plant records, {len(model['boms'])} BOMs, "
          f"{len(model['routings'])} routings, {len(model['info_records'])} info records, "
          f"{len(model['bps'])} business partners")
    for e in errors:
        print(f"  ERROR    {e}")
    for w in warnings:
        print(f"  WARNING  {w}")
    print(f"\n{len(errors)} error(s), {len(warnings)} warning(s). All data is fictional.")


if __name__ == "__main__":
    main()

Step 4: Run the checks

  1. Run the script with no options:

    • Windows (PowerShell):

      python unit1b\master_data.py
    • macOS / Linux:

      python unit1b/master_data.py
  2. You should see:

Master data checks on 2026-10-23: 6 plant records, 2 BOMs, 2 routings, 3 info records, 7 business partners
  WARNING  info record 5300000003 (CP-SK200): price 52.00 is +37% from standard price 38.00

0 error(s), 1 warning(s). All data is fictional.

No errors means every process in this unit has the master data it needs. The one warning is deliberate: Ridgeline charges far more for seal kits than Calder's standard price. That is a question for purchasing, not a broken process, which is why it is a warning.

  1. Try a later date. Source list entries have validity dates, so data that is complete today can expire:
python unit1b/master_data.py --on 2028-01-15
Master data checks on 2028-01-15: 6 plant records, 2 BOMs, 2 routings, 3 info records, 7 business partners
  ERROR    CP-MTR15 in CP11: source list required, but no entry is valid on 2028-01-15
  ERROR    CP-IMP200 in CP11: source list required, but no entry is valid on 2028-01-15
  WARNING  info record 5300000003 (CP-SK200): price 52.00 is +37% from standard price 38.00

2 error(s), 1 warning(s). All data is fictional.

On that date, neither motors nor impellers could be ordered. A check run weekly turns that future outage into a ticket today.

Step 5: Break one field and trace the ripple

  1. Run the lead-time scenario. The script changes the motor's info record lead time from 21 days to 2, then follows the consequences through order 1000001:
python unit1b/master_data.py --break planned-delivery
Scenario 'planned-delivery': one wrong field, traced across modules (synthetic):

  1. Purchasing master data  Info record 5300000001: planned delivery time 21 -> 2 days
  2. MRP                     Motors CP-MTR15 needed 2026-09-23; order placed 2026-09-20 instead of 2026-09-01
  3. Supplier                Orrin still needs 21 days: motors usable 2026-10-12, 19 days late
  4. Production              Pump assembly for order 1000001 slips 19 days
  5. Sales                   Delivery promised 2026-09-28 now 2026-10-17
  6. Finance                 Invoice and cash of 29,100.00 USD arrive 19 days later

Master data checks on 2026-10-23: 6 plant records, 2 BOMs, 2 routings, 3 info records, 7 business partners
  WARNING  CP-MTR15 in CP11: info record 5300000001 says 2 days, material says 21; MRP gives the info record priority
  WARNING  info record 5300000003 (CP-SK200): price 52.00 is +37% from standard price 38.00

0 error(s), 2 warning(s). All data is fictional.
  1. Notice what the checks caught: not an error, but a mismatch between the info record and the material. That is the most useful kind of rule, because it doesn't need to know the right value; it only needs two owners who should agree.
  2. Run the other three scenarios and read each trace:
python unit1b/master_data.py --break tolerance
python unit1b/master_data.py --break payment-terms
python unit1b/master_data.py --break standard-price

The tolerance scenario prints these first lines, and its checks add a policy warning:

Scenario 'tolerance': one wrong field, traced across modules (synthetic):

  1. Purchasing master data  Info record 5300000001: overdelivery tolerance 5% -> 50%
  2. Purchase order          New order for 20 motors inherits the 50% tolerance
  3. Goods receipt           Orrin ships 30; the receipt accepts all of them
  4. Inventory               10 unneeded motors in stock: 9,050.00 USD
  5. Invoice verification    Invoice matches the goods receipt, so no match exception is raised
  6. Finance                 Calder pays 9,050.00 USD for stock it didn't plan

The standard-price scenario ends with a warning that needs no reference value at all: the pump now costs less than its own components.

  WARNING  CP-ES200: standard price 28.50 is below its components' 1,293.00; check the cost estimate

Step 6: Screen new supplier requests for duplicates

  1. Run:
python unit1b/master_data.py --duplicates
New supplier requests vs. existing business partners (fictional data):

  R-0042  Orrin Motorworks, Inc.         1.00  LIKELY DUPLICATE 20000001 Orrin Motor Works (same tax number)
  R-0043  Ridgeline Bearing Supply Co.   1.00  LIKELY DUPLICATE 20000003 Ridgeline Bearing Supply (similar name)
  R-0044  Halden Valve Company           0.31  new              closest: 10000002 Hallam Fluid Systems (similar name)
  R-0045  Tamsfield Foundry              0.90  LIKELY DUPLICATE 20000002 Tamsfield Foundry (similar name, other country)

Likely duplicates go to a data steward. Nothing is created or merged automatically.
  1. Read the four cases. R-0042 has a different spelling but the same tax number: a duplicate. R-0043 scores 1.00 because "Co." is removed before comparing. R-0045 has the same name in a different country; it may be a duplicate, or a new US subsidiary of the German foundry. Only a person who checks can tell, which is exactly why the script queues and never merges.

Step 7: Write the field-ownership matrix

  1. Run:
python unit1b/master_data.py --matrix
Wrote unit1b/calder_field_ownership.md with 21 fields.
  1. Open unit1b/calder_field_ownership.md and press Ctrl+Shift+V (Windows, Linux) or Cmd+Shift+V (macOS). You should see a table: object, org level, the API field, the owner, who uses it and what breaks if it is wrong.

Step 8: Save your work in Git

git add unit1b/master_data.py unit1b/calder_field_ownership.md
git commit -m "Add Calder master data checks and field ownership"

What each part of the code does

Part What it does
PLANT_DATA to PRICES Calder's extra master data, shaped after A_ProductSupplyPlanning, A_BillOfMaterial, ProductionRoutingOperation, WorkCenterHeader, A_PurgInfoRecdOrgPlantData, A_PurchasingSource and A_SlsPrcgConditionRecord
TERMS_DAYS, MAX_OVERDELIVERY_PCT, PRICE_DEVIATION_PCT Calder's illustrative payment terms keys and policy limits
FIELD_OWNERSHIP The matrix: object, org level, API field, owner, consumers, failure
build_model() Combines the pack files with the extra data, as copies so scenarios never change the pack
check() Completeness rules (errors) and anomaly rules (warnings), including the mismatch and component-cost rules
ripple() Changes one field, then computes the downstream effect on order 1000001
normalize() and screen_duplicates() Clean company names, then score each request by tax number or fuzzy name match
write_matrix() Writes the matrix as a Markdown table

If something goes wrong

What you see What it means What to do
python is not recognized, or command not found The terminal can't find Python Repeat Step 1 of the course setup; on macOS and Linux use python3 until .venv is active
Can't find ... calder_pack/business_partners.json The pack hasn't been written, or you're in the wrong folder Run from orchestrate-course, then run Step 2
KeyError: '1000...' in screen_duplicates You added a business partner to the pack but not to BP_EXTRA Add its country and tax number to BP_EXTRA
invalid choice after --break The scenario name is misspelled Use one of the four names shown in the message
Invalid isoformat string The --on date isn't in YYYY-MM-DD form Write it like --on 2027-06-01
Different numbers from the topic Your pack or script was edited Run Step 2 again and compare your script with the one above
The preview shows a table as plain text You opened the file, not the preview Press Ctrl+Shift+V or Cmd+Shift+V

This script needs no network, so proxy or firewall problems don't apply here.

The SAP way

On a real system the master data lives in the ERP and, where it is governed, in SAP Master Data Governance.

Maintaining it. In SAP S/4HANA, business partners are created in transaction BP or in Fiori apps such as Manage Supplier Master Data, which creates the general role automatically once the supplier roles are filled (SAP Learning). Materials are maintained per view, with MM01 to MM03 still listed for create, change and display (SAP Learning). Sales prices use the Manage Prices - Sales app in the Public Edition course.

Reading it. As of the Cloudification Repository file read on 23 October 2026, SAP has released CDS views for each object in this topic, and marked the classic tables not to be released, with the views as successors (Cloudification Repository):

Object Released view (examples) Table not to be released
Business partner I_BusinessPartner BUT000
Customer and supplier I_Customer, I_CustomerSalesArea, I_Supplier, I_SupplierCompany KNA1, KNVV, LFA1, LFB1
Material I_Product, I_ProductSupplyPlanning, I_ProductPlantProcurement MARA, MARC
Info record I_PurchasingInfoRecordApi01 EINA, EINE
Source list I_MPPurchasingSourceItem EORD
Work center I_WorkCenter CRHD
Sales price I_SlsPrcgConditionRecord KONP

For remote reads, the OData services named in this topic (API_BUSINESS_PARTNER, API_PRODUCT_SRV, API_BILL_OF_MATERIAL_SRV, API_PRODUCTION_ROUTING, api_work_center, API_INFORECORD_PROCESS_SRV, API_PURCHASING_SOURCE_SRV, API_SLSPRICINGCONDITIONRECORD_SRV) match SAP Cloud SDK packages version 2.1.0. Check each on the SAP Business Accelerator Hub for your edition before relying on it; this run did not call them in the sandbox.

Governing it. SAP Master Data Governance offers central governance, consolidation and data quality management. As of November 2026, SAP Learning lists it on SAP S/4HANA, in SAP S/4HANA Cloud Private Edition, as SAP Master Data Governance, cloud edition, and in SAP S/4HANA Cloud Public Edition; check the scope of the option you are on (SAP Learning). For Private Edition customers, SAP's product page also describes Joule in master data governance for searching, creating and changing business partner data in natural language, requiring a Joule Base subscription and not available in all regions (SAP). Check the edition and region you are on before planning around it.

Licensing. The master data objects come with the ERP. SAP Master Data Governance and Joule are separate subscriptions; prices are on request.

Build vs. SAP

Need Build your own Use SAP Choose
Maintain records Spreadsheets and uploads ERP apps, transaction BP, material views SAP, always
Approval workflow for new records Tickets and email Master Data Governance change requests SAP when volume or audit needs justify the licence; tickets for a small team
Completeness and cross-object rules A script like this topic's Data quality rules in Master Data Governance Prototype rules in code, move stable ones into the tool
Duplicate screening Fuzzy matching or embeddings Consolidation and duplicate checks in Master Data Governance Both: your model can rank candidates for the steward
Read data into an AI app Copies in your own store Released views and APIs Released APIs, with a refresh rule for any cache

Architecture view

Where it sits. Master data is maintained in each ERP and, if governed centrally, in a hub that distributes it. At Calder (fictional), CP10's data lives in S/4HANA and CP20's in ECC, and both must agree on shared partners such as the intercompany pair 10000010 and 20000010.

flowchart LR
  REQ[Requesters] --> HUB[Governance hub<br/>or process]
  HUB --> S4[S/4HANA<br/>CP10]
  HUB --> ECC[ECC<br/>CP20]
  S4 -->|released APIs| AI[AI checks<br/>and assistants]
  AI -.suggestions.-> HUB

Non-functional requirements that decide the design

Requirement Typical target If missed
Data quality Critical fields complete and consistent, measured weekly Processes fail far from the cause; AI inherits the errors
Change control Every change to bank details, payment terms and prices approved and logged Fraud risk and audit findings
Latency of distribution New partners usable in every system the same day Orders wait for a supplier that exists in only one system
Consistency across systems One partner, one number, the same in S/4HANA and ECC Duplicate payments, broken intercompany matching
Volume Records sized per org level before migration Migration and maintenance effort grows with every plant and sales area

Integration and identity. Master data reaches other systems through released APIs and views; changes in governed setups flow from the hub to the ERPs. Interfaces that create customers or suppliers in S/4HANA must go through CVI. People maintain data under their own roles, so the authorization design (Roles and authorization design) should separate request, approval and payment. An AI checker reads with a read-only identity and writes only suggestions to a queue.

The decision to record (in the ADR form from Writing a solution design doc):

  • Context: Calder creates about a dozen suppliers a month across two systems; CP20 will move to S/4HANA.
  • Options: (a) SAP Master Data Governance as a central hub now; (b) a ticket-based process with rules checked by a script until CP20 migrates, then revisit; (c) each company code maintains its own data.
  • Decision: (b), with the business partner conversion of CP20 as a migration prerequisite, and (a) re-evaluated after the migration.
  • Consequences: Approval is manual and audit evidence lives in tickets. Rules must be maintained in code. Duplicates across the two systems are found by screening, not prevented by design.

What a design review will ask

  1. Who owns each object and field group, and where is that recorded?
  2. How are duplicates prevented at creation, and found among existing records?
  3. Which changes need a second approver, and how is that enforced?
  4. When does CP20's business partner conversion run, and what cleansing does it need first?
  5. Which AI tools read master data, with which identity, and can any of them write?

Production concerns

  • Security and authorizations. Changing a supplier's bank details and paying that supplier must be separate duties. Treat payment terms, bank data and prices as sensitive fields with their own approval. Any AI tool reads with a read-only identity.
  • Evaluation. For a duplicate model, build a labeled set of known duplicate and distinct pairs from past steward decisions. Measure precision (how many flags were real) and recall (how many real duplicates were flagged), and agree an acceptable miss rate with the data owners.
  • Cost. Each extra plant, sales area or purchasing organization multiplies records to maintain. Governance tooling has a licence cost; a weekly rule script costs almost nothing.
  • Operations. Run completeness and anomaly checks on a schedule, route findings to the owning team, and track the open count as a KPI. Expiring validity dates (source lists, prices) belong in the same run.
  • Clean core. Read through released views and APIs. Create customers and suppliers only through CVI-compliant interfaces. Don't keep shadow copies of master data in custom tables.

Pitfalls

  • Checking only the material master. The info record or contract may hold the lead time MRP actually uses.
  • Treating the business partner conversion as technical. It is a data cleansing project and must finish before an ECC conversion.
  • No owner for shared fields. Payment terms in sales and finance, lead times in planning and purchasing: decide who wins.
  • Letting an AI tool merge records. Suggest, queue, approve. Never merge or change payment data automatically.
  • Ignoring validity dates. Source lists and prices expire. Complete data today can stop purchasing next quarter.
  • Testing with incomplete master data. A process test that fails on a missing plant view tells you nothing about the process.

Exercise

Turn the generated matrix into Calder's field-ownership design, the deliverable a master data lead brings to a design review.

  1. Run python unit1b/master_data.py and confirm it prints 0 error(s), 1 warning(s).
  2. Run python unit1b/master_data.py --matrix and copy unit1b/calder_field_ownership.md to unit1b/calder_master_data_design.md.
  3. In the new file, add a column Approver and fill it for every row. Mark at least three rows as needing a second approver, and say why in one sentence below the table.
  4. Add four rows for fields the matrix lacks: A_SupplierCompany.ReconciliationAccount, A_SupplierCompany.PaymentBlockingReason, A_CustomerSalesArea.CustomerPricingProcedure and A_ProductSupplyPlanning.GoodsReceiptDuration. Fill every column.
  5. Run python unit1b/master_data.py --on 2027-06-01. Under a heading Expiring data, write which records the check flags on that date and who must act.
  6. Run each --break scenario. Under a heading Rules that caught it, write one line per scenario: which warning fired, and whether a rule could have blocked the change at entry instead.
  7. Add one line on the ECC difference: what Calder must do with CP20's customers and suppliers before its conversion.
  8. Preview the file to check the tables render, then commit it with Git.

Done when: unit1b/calder_master_data_design.md has the full matrix with an Approver column and 25 rows, an Expiring data section based on your 2027-06-01 run, a Rules that caught it section with four lines, and one ECC line. Keep it: Procure to pay end to end and Plan to produce end to end refer to these owners, and Data migration loads master data in this order.

Check yourself

Pick one answer for each question. The explanation appears after you choose.

1In API_BUSINESS_PARTNER, where does a supplier's payment terms field for company code CP10 live?

Answer: C. Company code data sits in A_SupplierCompany, which carries PaymentTerms and the reconciliation account. Purchasing org data has its own terms field, which is one reason ownership must be explicit.

2An interface creates Calder customers in S/4HANA with an old IDoc that writes the customer record directly. What happens?

Answer: C. SAP Learning states that IDocs and BAPIs that don't call CVI_EI_INBOUND_MAIN can no longer create or change customers and vendors. Interfaces must go through CVI so the business partner stays the leading object.

3The material's MRP view says 21 days, but MRP orders motors as if the lead time were 2 days. Where do you look first?

Answer: C. When MRP determines a source such as an info record, that source's planned delivery time takes priority over the material master's. The script's mismatch warning is built on exactly this.

4Why does the tolerance scenario raise no invoice exception?

Answer: C. With goods-receipt-based invoice verification, the invoice is checked against the receipt. If the receipt accepted too much, the match passes, so the control must sit on the master data.

5Why does the script make "standard price below the components' cost" a warning rather than an error?

Answer: B. Warnings mark values a person should check; errors mark data a process can't run with. A low standard price is very likely wrong, but the decision belongs to cost accounting.

6Your duplicate screen flags "Tamsfield Foundry" (US) against the existing German "Tamsfield Foundry" at 0.90. What do you do?

Answer: B. Same name, different country can be a duplicate or a legitimate subsidiary. The script lowers the score for that reason and never merges; a person with the facts decides.

7An AI assistant should read Calder's product master to answer planners' questions. Which source is right on a clean-core S/4HANA system?

Answer: B. The Cloudification Repository marks MARA and MARC not to be released, with I_Product and related views as successors. Remote apps use released APIs; any cache needs a refresh rule.

8Calder asks whether to buy a governance tool now or run rules in a script until CP20 migrates. Which argument supports waiting?

Answer: D. A tool enforces owners and rules; it can't define them. With the landscape about to change, proving the rules in a cheap script first is a defensible choice, recorded with its consequences.

Practice this in the lab

Orchestrate Lab is a free practice ERP in your browser, built around the fictional Calder Pumpworks. These are the places in it where you do what this topic teaches.

  • Stage 4 of 11Create master dataThe whole stage practices this topic, on Materials and Business partners.
  • Stage 8 of 11Take ordersPractice it on Materials.
  • ChapterProduct lifecycleChange how a product is built without stopping the line: an engineering change record with a reason and a date, a redesigned ES-200 impeller with its plant data, price and supplier, and a bill of material with two dated lives, the old impeller ending and the new one starting on the change's date, so every order reads the bill valid on the day it starts; then the planner runs MRP across the date and reads the old impeller's run-out and the new one's first order.
  • ChapterQualityInspect what Calder buys before anyone uses it: an inspection setup and characteristics for seal kits, goods receipts that go to quality inspection and open inspection lots, results measured on a sample, and the usage decision that releases a good delivery to unrestricted stock or blocks a bad one, while planning counts only what can be used. Then the final inspection of a pump Calder builds: ES-300 tested at production receipt before anyone can sell it, and an order that confirms only the pumps that passed until a recheck after the decision. Then the complaint: a supplier's invoice blocked for quality, a quality notification, a return delivery and the credit memo that settles it. Then the supplier's replacement inspected and its invoice released by the decision, and the supplier's quality record read for what it can and can't say. Last, ES-300's final inspection fed by the test bench: one measurement per pump, recorded once against its serial number.
  • ChapterTransportationPlan Calder's freight from Rockford: carriers and their agreements, freight units from deliveries, freight orders, the carrier chosen by cost, loads with stops, truck capacity and a departure worked back from the dates, the final charges with stops and detention after free time, tendering each load to a carrier that has to accept it, following the truck by its execution events, and settling the freight: each carrier invoice checked against the agreed charges, the freight cost shared among the deliveries so each customer's margin shows what its freight really cost, and inbound freight on purchased motors planned on the purchase order and carried into the motors' cost.
  • ChapterWarehouseLay out Calder's Rockford distribution center: storage types, sections and bins, then put stock away, pick, pack and count it, put people on the work and watch it.
  • DrillThe spare bearing nobody can orderPlanning, about 6 minutes
  • DrillThe purchase order that won't saveProcurement, about 5 minutes

Stages build on each other: a stage link opens the core path at that stage. To start there without doing the earlier stages yourself, use Do this stage for me on the stage before it. A chapter starts inside your own company once the core path is done (or the lab finishes the core for you first); drills and exercises start from their own prepared copy. Everything is saved in this browser only.

Sources

Sign in to track your progress

We'll email you a one-time sign-in link. No password needed.

or

Tell us a little about you

Optional, every field. It helps us pitch answers to your questions at the right level and decide which topics to write next. It is never shown publicly, and you can change or clear it anytime from the account menu.

SAP areas you work in