The SAP data model for AI engineers: tables, CDS views and OData
Know where SAP business data lives, why AI should read it through released CDS views and APIs, and how to turn one sales order into context a model can use.
SAP keeps business data in thousands of database tables with short, technical names. A sales order alone is spread over several of them. Nobody should point an AI system at those tables.
On top of the tables, S/4HANA has a virtual data model: a layer of CDS views that give the same data business names, such as SalesOrder and SoldToParty. Some of these views are released: SAP promises to keep them stable across upgrades. SAP's APIs are built on views of this kind and publish the same business objects to outside systems.
So the rule for AI work is short. Read business objects through released APIs and views, not tables. Then write down what every field means, in plain words, so the model and the people checking it read the data the same way. That written page is a data dictionary, and it is one of the most useful things an AI team produces.
Take the running example: blocked sales orders in order-to-cash. An assistant that explains why an order is stuck needs the order header (customer, value, block reasons), the items (what was ordered), the partners (who ordered, where it ships) and the customer master record (is the customer blocked centrally?). That is four kinds of record from two APIs.
How the team gets that data decides four things a leader cares about:
Correct answers. A model that sees DeliveryBlockReason: 01 with no explanation will guess what 01 means, and guess with confidence. The meaning of that code is set in each company's configuration. A data dictionary that says "code; look it up" prevents a whole class of wrong answers.
Upgrade risk. SAP's clean core guidance, as described by SAP News in August 2025, rates extensions that use only released, stable interfaces at the top level (A). Extensions that access SAP's internal objects sit at level C. Tables such as VBAK (sales document header) are internal objects; SAP's own list marks them "not to be released" and names a released view to use instead.
Cost. Reading the right object once is cheaper than reverse-engineering tables, then rebuilding the work after the next upgrade.
Control. Released APIs run under a defined technical identity with defined authorizations. A table copy has none of that unless someone rebuilds it.
The virtual data model (VDM) is S/4HANA's layer of CDS views. Basic views sit directly on tables and give fields business names. Composite views combine them. Consumption views serve one app or report. Views for outside callers carry the prefix A_ and sit behind the OData APIs on the SAP Business Accelerator Hub.
Released objects are listed by SAP. The Cloudification Repository, published by SAP on GitHub, lists for each object whether it is released, deprecated or not to be released, and names successors. For example, the table VBAK points to the released view I_SalesDocument.
The SAP Business Accelerator Hub (api.sap.com) lists APIs such as Sales Order (A2X), API_SALES_ORDER_SRV, with their structure. It also lists released CDS views as their own content type.
For analytics and AI at scale, SAP offers data products in SAP Business Data Cloud and business semantics through SAP Knowledge Graph. Both are covered in SAP Knowledge Graph and Business Data Cloud.
Most SAP business documents follow the same shape: a header (one record per document) and items (one record per line), plus links to master data (customers, suppliers, materials) that lives much longer than any single document.
Sales order (order-to-cash)
Purchase order (procure-to-pay)
Journal entry (record-to-report)
Header table (internal)
VBAK
EKKO
BKPF
Item table (internal)
VBAP
EKPO
ACDOCA (Universal Journal); BSEG (entry view)
Released views SAP names as successors
I_SalesDocument, I_SalesDocumentItem
I_PurchaseOrderAPI01, I_PurchaseOrderItemAPI01
I_JournalEntry; I_JournalEntryItem and others for ACDOCA; I_OperationalAcctgDocItem for BSEG
Table and view names come from SAP's Cloudification Repository files of 16 October 2026; API names from SAP's published models and SAP Knowledge Base Article 2821106. Check them for your release.
What a leader should take from the table: the pattern repeats. A team that has traced one business object properly can trace the next one in days, not weeks. And finance is the exception worth knowing: since S/4HANA, line items for general ledger, asset accounting, controlling and other components live together in one table, the Universal Journal.
"The table names are the real data model." They are the storage. The business meaning lives in the views and APIs, with names a person can read.
"If a view exists, we can use it." Only released views come with a stability promise. Similar-looking names are not all released; SAP's list says which ones are.
"The model will figure out what the fields mean." It will guess from the names. Codes such as block reasons need the company's own configuration to explain them.
"One API call gets the whole order." An order is a header, items, partners, prices and texts, each a separate linked record, and the customer lives in a different API.
"Customer number and business partner number are the same thing." They are different keys in different records. A design should look one up from the other, never assume they match.
Pick one answer for each question. The explanation appears after you choose.
1Your team proposes feeding sales order data to an AI assistant straight from the tables VBAK and VBAP. What is the main objection?
Answer: A. SAP's own list marks VBAK and VBAP "not to be released" and names released views such as I_SalesDocument as successors. Reading internal objects puts an extension at clean core level C, with more upgrade risk than level A.
2What does the virtual data model add on top of SAP's tables?
Answer: A. The VDM is a layer of CDS views. Basic views sit on tables and give fields business names; higher layers combine and specialise them. It does not copy the data.
3An assistant reads DeliveryBlockReason "01" and tells a user the order is blocked for credit reasons. What went wrong?
Answer: A. Block reason codes are configured per company. Without a data dictionary that says "this is a code; look it up", a model will guess, and sound sure while doing it.
4Why is a data dictionary worth a leader's attention?
Answer: A. The dictionary is shared ground truth for people and the model: what each field means, its type, and rules such as "codes need looking up". It does not replace authorizations or testing.
5A vendor says "a sales order is one record, so one API call is enough." What is the best reply?
Answer: D. A sales order is a header with linked items, partners, prices and texts. The customer master record sits in the Business Partner API. An honest design names each record it reads.
6Your finance team asks where journal entry line items live in S/4HANA. What should you know before the design meeting?
Answer: A. Since S/4HANA, the Universal Journal (ACDOCA) holds line items for several finance components together. SAP also keeps an entry view, so a design should say which view it reads and why.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
In Calling your first SAP API you learned to read one API's contract and page through its records. This topic steps back and asks where those records come from, and how to describe them so a model reads them correctly.
The one idea: read the business object, not the storage. SAP data sits in three layers:
Tables store it. Names are short and technical (VBAK, VBAP, KNA1). They are internal: SAP can change them.
CDS views of the virtual data model describe it in business terms (I_SalesDocument, I_SalesOrder, I_Customer). The released ones come with a stability promise.
APIs publish business objects to outside systems (API_SALES_ORDER_SRV with A_SalesOrder). They are built on views and describe themselves in $metadata.
An AI engineer works at layers 2 and 3, and adds a fourth thing SAP doesn't ship: a data dictionary that says, for the exact fields you read, what each means and how to read it. The metadata gives you most of that dictionary for free. Your job is to add the rules the metadata can't know, such as "this is a configured code".
Two kinds of record matter for almost every AI use case:
Master data lives for years and changes rarely: business partners, customers, suppliers, materials, G/L accounts.
Transactional data records business events: sales orders, purchase orders, deliveries, invoices, journal entries. Each document points to master data by key.
A blocked-order assistant needs both. The order says what happened; the customer record says who it happened with, including blocks that apply to every order from that customer.
Business documents are split into a header (one record per document) and items (one record per line). The item's key repeats the header's key and adds an item number. For the Sales Order API, SAP's generated model lists:
Entity set
Key
One record per
A_SalesOrder
SalesOrder
order
A_SalesOrderItem
SalesOrder + SalesOrderItem
order line
A_SalesOrderHeaderPartner
SalesOrder + PartnerFunction
partner role on the order
The purchase order API follows the same pattern: A_PurchaseOrder keyed by PurchaseOrder, and A_PurchaseOrderItem keyed by PurchaseOrder + PurchaseOrderItem.
Finance is different underneath. SAP Learning describes the Universal Journal, table ACDOCA, as holding line items for general ledger, asset accounting and controlling together. SAP Knowledge Base Article 2821106 adds that table BSEG remains the entry view of operational accounting documents, while ACDOCA is the ledger view, and names A_JOURNALENTRYITEMBASIC as the API for extracting journal entry items. When an AI feature reads "journal lines", ask which view it means.
The virtual data model (VDM) is S/4HANA's set of CDS views, in layers:
flowchart BT
T["Tables<br/>VBAK, VBAP, KNA1"] --> B["Basic views (I_)<br/>business names"]
B --> C["Composite views<br/>combine basics"]
B --> A["Remote API views (A_)<br/>one business object"]
C --> U["Consumption views (C_)<br/>one app or query"]
A --> O["OData API<br/>API_SALES_ORDER_SRV"]
Basic views read the tables and give fields business names. SAP PRESS's overview of the VDM gives the classic example: the technical field MATNR becomes Product. Higher layers don't read tables at all; basic views isolate them.
Composite views combine basic views, for example into analytical cubes.
Consumption views (C_) serve one specific app or query.
Remote API views (A_) expose one business object to outside callers, and the OData services built on them are published on the SAP Business Accelerator Hub.
Names line up across layers. A long-standing SAP Community post by SAP's Thomas Schneider explains that released I_ views usually match OData entities by name, with A_Product and I_Product (which reads table MARA) as the example. It also notes that field names stay consistent because S/4HANA uses a global field catalog. That is why SoldToParty means the same thing in the API and in the view.
"Released" is the word that matters. SAP publishes the list in the Cloudification Repository on GitHub, as JSON files used by the ABAP Test Cockpit's cloud readiness checks. Each object has a state, such as released, deprecated or notToBeReleased (the private cloud file also uses notToBeReleasedStable), and objects that are not released often name successors. In the SAP Cloud ERP public cloud file of 16 October 2026:
The same file lists I_SalesOrder, I_SalesOrderItem and I_SalesOrderPartner as released. Watch the details: in the private cloud file, I_PurchaseOrder is not released (its state is notToBeReleasedStable), while I_PurchaseOrderAPI01 is. A plausible name is not a promise.
This connects to clean core. SAP News described four levels in August 2025: level A uses only publicly released, stable interfaces; level B may also use classic APIs; level C accesses internal objects; level D includes modifications and write access to SAP tables. An AI system that reads through released APIs stays at level A.
Every OData V2 API answers $metadata with an XML contract. You met it in the previous topic. Three details matter for a data dictionary:
Entity sets point to entity types. In real SAP metadata the type name usually ends in Type. The API_BUSINESS_PARTNER metadata in SAP's cloud-cap-risk-management sample declares the entity set A_Customer with the type API_BUSINESS_PARTNER.A_CustomerType. Code that looks up A_Customer among the types finds nothing; it must go through the entity set.
Navigation goes through associations. A navigation property such as to_Customer names an association and a role. The association's ends name the target type and its multiplicity (1, 0..1 or *). That is how you learn that to_Item leads to many items.
SAP adds human-readable annotations. SAP's annotation reference for OData V2 defines sap:label (a short caption), sap:quickinfo (tooltip text), sap:unit (the field holding the currency or unit for an amount) and sap:text (a field holding text for a code).
Labels are written for screens, so they can be terse. In that sample metadata, OrderIsBlockedForCustomer on the sales-area record has the label Ord.blk:sls ar. and the quickinfo "Customer order block (sales area)". Prefer the quickinfo when it exists. Some properties, such as BusinessPartnerFullName there, have no label at all.
And some things the metadata can never tell you:
What a code means in this company. Block reasons, partner functions, order types and statuses are configured or defined per release. The field says "Delivery Block (Document Header)", not what 01 means.
Which fields matter for the use case. The sales order header alone has more than 80 fields. You pick.
How records connect across APIs. The order's sold-to party is a customer number. The customer record lives in API_BUSINESS_PARTNER. No navigation property crosses that line; your code does.
flowchart LR
H["A_SalesOrder"] -->|to_Item| I["A_SalesOrderItem"]
H -->|to_Partner| P["A_SalesOrderHeaderPartner"]
P -.->|Customer = Customer| C["A_Customer<br/>(API_BUSINESS_PARTNER)"]
C -.->|Customer = Customer| BP["A_BusinessPartner"]
The dotted lines are joins you write by matching keys. The solid ones come from the metadata.
#Build it yourself: trace a blocked order and write its data dictionary
You will build one script, data_model_trace.py. It reads the contracts of two SAP APIs, finds a blocked sales order, follows it to its items and partners, crosses into the Business Partner API for the customer, and writes two files: the trace itself and a data dictionary generated from the metadata, with your rules added. Together they are the context an LLM prompt needs in Unit 5.
flowchart LR
A["A. Two $metadata<br/>contracts"] --> B["B. Blocked order<br/>(header)"]
B --> C["C. Items"]
B --> D["D. Partners"]
D --> E["E. Customer and<br/>business partner"]
E --> F["F. Trace + data<br/>dictionary"]
The script runs in two modes. --sample uses made-up data built into the script, shaped like SAP's real metadata: no account, no internet, and the output matches this page. Without --sample it calls SAP's sandbox with your key.
Before you start: complete Set up your computer for this course. It creates your orchestrate-course folder with its .venv, installs requests and python-dotenv, and stores SAP_API_KEY in .env. Do Calling your first SAP API first: this walkthrough builds on its ideas and uses the same unit01 folder.
The course setup above. Nothing new to install: the script uses only requests, python-dotenv and modules built into Python.
For the sample mode: nothing else. Free, works offline.
For the sandbox mode: your free SAP Business Accelerator Hub key in .env as SAP_API_KEY, and a network that can reach sandbox.api.sap.com. Free.
About 60 to 90 minutes.
#Step 1: Find the object on the SAP Business Accelerator Hub
Look at the business object the way SAP presents it before you write code.
Open api.sap.com in your browser and click Log On at the top right.
In the search box, type Sales Order (A2X) and open the API whose technical name is API_SALES_ORDER_SRV.
Open API Reference. In the list on the left, find the groups for A_SalesOrder, A_SalesOrderItem and A_SalesOrderHeaderPartner. Those are the three records you will read.
Go back to the search box, type API_BUSINESS_PARTNER and open the Business Partner API with that technical name. Find A_Customer and A_BusinessPartner in its API Reference.
Search once more, this time for I_SalesOrder. Look for results of the content type CDS Views. These are released views for developers working inside S/4HANA; you won't call them in this walkthrough, but notice how the names line up with the API.
#Step 2: Open your course folder and turn on the environment
Open VS Code, choose File > Open Folder and open your orchestrate-course folder.
Open a terminal with Terminal > New Terminal.
Turn on the virtual environment:
Windows (PowerShell):
.venv\Scripts\Activate.ps1
macOS / Linux:
source .venv/bin/activate
Check that the prompt starts with (.venv).
Move into the Unit 1 folder, creating it first if it doesn't exist:
Windows (PowerShell):
New-Item -ItemType Directory -Force unit01
cd unit01
In VS Code's file list, right-click unit01, choose New File and name it data_model_trace.py.
Paste the whole script below and save with Ctrl+S (Windows, Linux) or Cmd+S (macOS).
"""The SAP data model for AI engineers: trace one blocked sales order and write a data dictionary.
How to run (from the unit01 folder, with the course .venv turned on):
python data_model_trace.py --sample made-up data on your computer; no account, no internet
python data_model_trace.py SAP Business Accelerator Hub sandbox (needs SAP_API_KEY in .env)
python data_model_trace.py --order 1234567 trace this order instead of the first blocked one
The script only reads. It writes two files next to itself:
blocked_order_trace.json the order, its items, its partners and the sold-to customer
data_dictionary.md what every field in that file means, taken from the APIs' metadata
"""
import argparse
import json
import os
import re
import sys
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from pathlib import Path
SANDBOX = "https://sandbox.api.sap.com/s4hanacloud"
SERVICES = {
"sales": "/sap/opu/odata/sap/API_SALES_ORDER_SRV",
"bp": "/sap/opu/odata/sap/API_BUSINESS_PARTNER",
}
# What we read from each entity set: (service, entity set, fields, one-line purpose).
# Every field name must exist in that service's $metadata; step A checks it.
PLAN = [
("sales", "A_SalesOrder",
["SalesOrder", "SalesOrderType", "SalesOrganization", "SoldToParty", "CreationDate",
"RequestedDeliveryDate", "TotalNetAmount", "TransactionCurrency", "DeliveryBlockReason",
"HeaderBillingBlockReason", "TotalCreditCheckStatus"],
"Sales order header: one record per order. Who ordered, when, how much, and any blocks."),
("sales", "A_SalesOrderItem",
["SalesOrder", "SalesOrderItem", "Material", "SalesOrderItemText", "RequestedQuantity",
"RequestedQuantityUnit", "NetAmount", "TransactionCurrency", "ItemBillingBlockReason"],
"Sales order item: one record per line of an order. What was ordered and how much."),
("sales", "A_SalesOrderHeaderPartner",
["SalesOrder", "PartnerFunction", "Customer"],
"Partners of the order: one record per role (sold-to, ship-to and others)."),
("bp", "A_Customer",
["Customer", "CustomerName", "OrderIsBlockedForCustomer", "DeliveryIsBlocked",
"BillingIsBlockedForCustomer"],
"Customer master record: the customer's name and central blocks for all sales areas."),
("bp", "A_BusinessPartner",
["BusinessPartner", "Customer", "BusinessPartnerFullName", "BusinessPartnerCategory",
"BusinessPartnerIsBlocked"],
"Business partner: the central record that the customer role belongs to."),
]
# Rules the metadata cannot tell you. These go into the dictionary as notes.
CODE_NOTE = "A code, not text. Its meaning is set in each company's configuration. Empty means none."
NOTES = {
"DeliveryBlockReason": CODE_NOTE,
"HeaderBillingBlockReason": CODE_NOTE,
"ItemBillingBlockReason": CODE_NOTE,
"TotalCreditCheckStatus": "A status code. Look up its values for your release; don't guess.",
"OrderIsBlockedForCustomer": CODE_NOTE,
"DeliveryIsBlocked": CODE_NOTE,
"BillingIsBlockedForCustomer": CODE_NOTE,
"PartnerFunction": "A code for the partner's role. Check the codes your system returns.",
"SalesOrderType": "A code defined in configuration.",
"SalesOrganization": "An organizational unit key defined in configuration.",
}
# ------------------------------------------------------------------ talking to the API
class LiveSAP:
"""Sends real HTTP GET requests to SAP's sandbox."""
def __init__(self, key: str):
import requests # imported here so --sample works even without the library
self.requests = requests
self.session = requests.Session()
self.session.headers.update({"APIKey": key})
def get(self, service: str, path: str, params: dict | None = None) -> tuple[int, str]:
accept = "application/xml" if path == "/$metadata" else "application/json"
try:
resp = self.session.get(SANDBOX + SERVICES[service] + path, params=params,
headers={"Accept": accept}, timeout=30)
except self.requests.exceptions.RequestException as err:
sys.exit(f"Could not reach sandbox.api.sap.com ({type(err).__name__}). "
"Check your network or proxy, or run with --sample.")
return resp.status_code, resp.text
class SampleSAP:
"""Answers like the sandbox would, from made-up data. Understands $top, $select and a
simple $filter of the form Field eq 'value', which is all this script uses."""
def get(self, service: str, path: str, params: dict | None = None) -> tuple[int, str]:
params = params or {}
if path == "/$metadata":
return 200, SAMPLE_METADATA[service]
rows = None
one = re.fullmatch(r"/(\w+)\('([^']*)'\)", path)
nav = re.fullmatch(r"/A_SalesOrder\('(\d+)'\)/(to_Item|to_Partner)", path)
if nav:
source = SAMPLE_ITEMS if nav.group(2) == "to_Item" else SAMPLE_PARTNERS
rows = source.get(nav.group(1), [])
elif one and one.group(1) in SAMPLE_SETS[service]:
hits = [r for r in SAMPLE_SETS[service][one.group(1)]
if r[SAMPLE_KEYS[one.group(1)]] == one.group(2)]
if not hits:
return 404, json.dumps({"error": {"message": {"value": "Resource not found"}}})
return 200, json.dumps({"d": select(hits[0], params)})
elif path.lstrip("/") in SAMPLE_SETS[service]:
rows = SAMPLE_SETS[service][path.lstrip("/")]
if rows is None:
return 404, json.dumps({"error": {"message": {"value": f"Resource not found: {path}"}}})
if "$filter" in params:
field, value = re.fullmatch(r"(\w+) eq '([^']*)'", params["$filter"]).groups()
rows = [r for r in rows if r.get(field) == value]
rows = rows[:int(params.get("$top", len(rows)))]
return 200, json.dumps({"d": {"results": [select(r, params) for r in rows]}})
def select(row: dict, params: dict) -> dict:
if "$select" not in params:
return row
return {k: row[k] for k in params["$select"].split(",") if k in row}
# ------------------------------------------------------------------ step A: the contract
def local(name: str) -> str:
"""'API_SALES_ORDER_SRV.A_SalesOrderType' -> 'A_SalesOrderType'; '{ns}Property' -> 'Property'."""
return name.rsplit("}", 1)[-1].rsplit(".", 1)[-1]
def sap_attr(node, name: str) -> str:
"""Read an SAP annotation such as sap:label, whatever namespace prefix the file uses."""
return next((v for k, v in node.attrib.items() if k.endswith("}" + name)), "")
def read_contract(xml_text: str) -> dict:
"""Parse OData V2 $metadata into: entity set -> its key, properties and navigation."""
root = ET.fromstring(xml_text.encode("utf-8"))
types, ends, sets = {}, {}, {}
for node in root.iter():
tag = local(node.tag)
if tag == "EntityType":
key = [ref.get("Name") for ref in node.iter() if local(ref.tag) == "PropertyRef"]
props, navs = {}, {}
for child in node:
if local(child.tag) == "Property":
props[child.get("Name")] = {
"type": child.get("Type", "").replace("Edm.", ""),
"max": child.get("MaxLength", ""),
"label": sap_attr(child, "label"),
"quickinfo": sap_attr(child, "quickinfo"),
"unit": sap_attr(child, "unit"),
}
elif local(child.tag) == "NavigationProperty":
navs[child.get("Name")] = (local(child.get("Relationship", "")),
child.get("ToRole"))
types[node.get("Name")] = {"key": key, "props": props, "navs": navs}
elif tag == "Association":
for end in node:
if local(end.tag) == "End":
ends[(node.get("Name"), end.get("Role"))] = (local(end.get("Type")),
end.get("Multiplicity"))
elif tag == "EntitySet":
sets[node.get("Name")] = local(node.get("EntityType"))
set_of_type = {t: s for s, t in sets.items()}
contract = {}
for set_name, type_name in sets.items():
t = types[type_name]
navigation = {}
for nav_name, (assoc, role) in t["navs"].items():
target_type, many = ends.get((assoc, role), ("?", "?"))
navigation[nav_name] = {"target": set_of_type.get(target_type, target_type),
"many": many == "*"}
contract[set_name] = {"type": type_name, "key": t["key"], "props": t["props"],
"navigation": navigation}
return contract
# ------------------------------------------------------------------ steps B to E: the data
def get_json(api, service: str, path: str, params: dict | None = None):
status, text = api.get(service, path, params)
if status != 200:
hints = {401: "the key is missing or wrong: check SAP_API_KEY in .env",
403: "the key is valid but not allowed to do this",
404: "the address is wrong, or the record does not exist",
429: "too many calls: wait a minute and run again"}
sys.exit(f"Stopped: HTTP {status}, {hints.get(status, 'see the message below')}\n{text[:300]}")
data = json.loads(text)["d"]
return data.get("results", data) if isinstance(data, dict) else data
def fields_of(entity_set: str) -> str:
return ",".join(next(p[2] for p in PLAN if p[1] == entity_set))
def odata_date(value):
"""OData V2 JSON sends dates as /Date(milliseconds since 1970)/. Turn that into 2026-10-18."""
match = re.search(r"/Date\((-?\d+)", value or "") if isinstance(value, str) else None
if not match:
return value
return datetime.fromtimestamp(int(match.group(1)) / 1000, tz=timezone.utc).date().isoformat()
def tidy(row: dict) -> dict:
"""Keep API field names (the dictionary explains them); fix dates; drop __metadata."""
return {k: odata_date(v) for k, v in row.items() if not k.startswith("__")}
def safe_key(value) -> str:
"""Keys go into URLs and $filter text, so allow only short plain codes (no quotes, slashes or spaces)."""
if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,10}", value):
sys.exit(f"Stopped: {value!r} is not a valid key (letters, digits, - or _, at most 10).")
return value
def is_blocked(order: dict) -> bool:
return bool(order.get("DeliveryBlockReason") or order.get("HeaderBillingBlockReason"))
def find_order(api, wanted: str | None, scan: int) -> dict:
"""Step B: the header. Either the order you asked for, or the first blocked one we find."""
if wanted:
return get_json(api, "sales", f"/A_SalesOrder('{safe_key(wanted)}')", {"$select": fields_of("A_SalesOrder")})
orders = get_json(api, "sales", "/A_SalesOrder",
{"$top": str(scan), "$select": fields_of("A_SalesOrder"), "$orderby": "SalesOrder"})
blocked = [o for o in orders if is_blocked(o)]
print(f" Scanned {len(orders)} orders; with a delivery or billing block: {len(blocked)}")
if blocked:
return blocked[0]
if not orders:
sys.exit(" The service returned no orders. Nothing to trace.")
print(" No blocked order in this slice. That is a valid answer; tracing the first order instead.")
return orders[0]
# ------------------------------------------------------------------ step F: the dictionary
def describe(prop: dict) -> str:
"""Prefer the longer tooltip text (sap:quickinfo); fall back to the short label."""
return prop["quickinfo"] or prop["label"] or "(no label in metadata)"
def build_dictionary(contracts: dict, source: str) -> str:
today = datetime.now(timezone.utc).date().isoformat()
lines = [
"# Data dictionary: blocked sales order trace",
"",
f"Generated {today} from the $metadata of API_SALES_ORDER_SRV and API_BUSINESS_PARTNER "
f"({source}). Field names are the exact API names used in blocked_order_trace.json.",
"",
"## How the records connect",
"",
"- A_SalesOrder --to_Item--> A_SalesOrderItem (many). Item key: SalesOrder + SalesOrderItem.",
"- A_SalesOrder --to_Partner--> A_SalesOrderHeaderPartner (many). One record per partner role.",
"- A_SalesOrderHeaderPartner.Customer = A_Customer.Customer (a different API: API_BUSINESS_PARTNER).",
"- A_Customer.Customer = A_BusinessPartner.Customer. The business partner number may differ.",
"",
"## Rules",
"",
"- Amounts are decimals sent as text. Read the currency from the field named in Notes.",
"- Codes are not text. Do not guess what a code means; say the code and that it needs looking up.",
"- Empty text means \"not set\", not a code.",
]
for service, entity_set, fields, purpose in PLAN:
c = contracts[service][entity_set]
lines += ["", f"## {entity_set}", "", purpose, f"Key: {' + '.join(c['key'])}.", "",
"| Field | Meaning (from SAP metadata) | Type | Notes |", "| --- | --- | --- | --- |"]
for name in fields:
p = c["props"][name]
typ = p["type"] + (f"({p['max']})" if p["max"] else "")
notes = []
if name in c["key"]:
notes.append("Key.")
if p["unit"]:
notes.append(f"Unit or currency in {p['unit']}.")
if name in NOTES:
notes.append(NOTES[name])
lines.append(f"| {name} | {describe(p)} | {typ} | {' '.join(notes)} |")
return "\n".join(lines) + "\n"
# ------------------------------------------------------------------ main
def main() -> None:
parser = argparse.ArgumentParser(description="Trace one blocked sales order and write a data dictionary.")
parser.add_argument("--sample", action="store_true", help="use made-up data; no key or internet")
parser.add_argument("--order", help="trace this sales order number instead of the first blocked one")
parser.add_argument("--scan", type=int, default=100, help="how many orders to scan for a blocked one")
args = parser.parse_args()
if args.sample:
api, source = SampleSAP(), "sample data"
print("Target: sample data on your computer (made up, shaped like SAP's APIs)\n")
else:
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
key = os.environ.get("SAP_API_KEY")
if not key:
sys.exit("SAP_API_KEY is not set. Add it to .env, or run with --sample.")
api, source = LiveSAP(key), "SAP sandbox"
print("Target: SAP Business Accelerator Hub sandbox\n")
print("A. Read both contracts ($metadata)")
contracts = {}
for service in SERVICES:
status, text = api.get(service, "/$metadata")
if status != 200:
sys.exit(f" Could not read $metadata of {SERVICES[service]}: HTTP {status}")
contracts[service] = read_contract(text)
for service, entity_set, fields, _ in PLAN:
c = contracts[service].get(entity_set)
if not c:
sys.exit(f" {entity_set} is not in {SERVICES[service]}. Check the entity set name.")
missing = [f for f in fields if f not in c["props"]]
if missing:
sys.exit(f" {entity_set} has no field {', '.join(missing)}. Check the field list.")
print(f" {entity_set:<26} key {'+'.join(c['key']):<26} {len(c['props']):>3} fields, "
f"{len(c['navigation'])} navigation properties")
for nav in ("to_Item", "to_Partner"):
n = contracts["sales"]["A_SalesOrder"]["navigation"][nav]
print(f" A_SalesOrder --{nav}--> {n['target']} ({'many' if n['many'] else 'one'})")
print("\nB. Find the order (header)")
order = tidy(find_order(api, args.order, args.scan))
oid = safe_key(order["SalesOrder"])
print(f" Order {oid}: sold-to {order.get('SoldToParty')}, "
f"{order.get('TotalNetAmount')} {order.get('TransactionCurrency')}, "
f"delivery block '{order.get('DeliveryBlockReason')}', "
f"billing block '{order.get('HeaderBillingBlockReason')}'")
print("\nC. Follow to_Item (what was ordered)")
items = [tidy(i) for i in get_json(api, "sales", f"/A_SalesOrder('{oid}')/to_Item",
{"$select": fields_of("A_SalesOrderItem")})]
for i in items:
print(f" item {i['SalesOrderItem']}: {i.get('RequestedQuantity')} {i.get('RequestedQuantityUnit')} "
f"{i.get('Material')} ({i.get('SalesOrderItemText')})")
print("\nD. Follow to_Partner (who is involved)")
partners = [tidy(p) for p in get_json(api, "sales", f"/A_SalesOrder('{oid}')/to_Partner",
{"$select": fields_of("A_SalesOrderHeaderPartner")})]
for p in partners:
print(f" partner function {p['PartnerFunction']}: customer {p.get('Customer') or '-'}")
print("\nE. Cross into the Business Partner API (the sold-to customer)")
sold_to = safe_key(order.get("SoldToParty"))
customer = tidy(get_json(api, "bp", f"/A_Customer('{sold_to}')", {"$select": fields_of("A_Customer")}))
print(f" A_Customer {sold_to}: {customer.get('CustomerName')}; central blocks: "
f"order '{customer.get('OrderIsBlockedForCustomer')}', "
f"delivery '{customer.get('DeliveryIsBlocked')}', billing '{customer.get('BillingIsBlockedForCustomer')}'")
bps = get_json(api, "bp", "/A_BusinessPartner",
{"$filter": f"Customer eq '{sold_to}'", "$select": fields_of("A_BusinessPartner")})
bp = tidy(bps[0]) if bps else None
if bp:
print(f" A_BusinessPartner {bp['BusinessPartner']}: {bp.get('BusinessPartnerFullName')}, "
f"category {bp.get('BusinessPartnerCategory')}, centrally blocked: {bp.get('BusinessPartnerIsBlocked')}")
else:
print(" No business partner points to this customer number. Valid, but worth asking about.")
print("\nF. Save the trace and the data dictionary")
here = Path(__file__).parent
trace = {"source": source, "sales_order": order, "items": items, "partners": partners,
"sold_to": {"customer": customer, "business_partner": bp}}
(here / "blocked_order_trace.json").write_text(json.dumps(trace, indent=2), encoding="utf-8")
dictionary = build_dictionary(contracts, source)
(here / "data_dictionary.md").write_text(dictionary, encoding="utf-8")
print(f" Wrote blocked_order_trace.json ({len(items)} items, {len(partners)} partners)")
print(f" Wrote data_dictionary.md ({len(dictionary.splitlines())} lines, {len(dictionary)} characters)")
print(" First rows of the A_SalesOrder table in the dictionary:")
start = dictionary.index("| Field |", dictionary.index("## A_SalesOrder"))
for line in dictionary[start:].splitlines()[2:6]:
print(" " + line)
# ------------------------------------------------------------------ made-up sample data
# Shaped like SAP's metadata: entity types end in "Type", entity sets point to them, and
# navigation properties point to associations. Labels follow SAP's published models.
def _prop(name, typ, label, quickinfo="", max_len="", unit=""):
extra = f' MaxLength="{max_len}"' if max_len else ""
extra += f' sap:quickinfo="{quickinfo}"' if quickinfo else ""
extra += f' sap:unit="{unit}"' if unit else ""
return f'<Property Name="{name}" Type="Edm.{typ}"{extra} sap:label="{label}"/>'
def _metadata(namespace, types, assocs):
body = []
for type_name, key, props, navs in types:
body.append(f'<EntityType Name="{type_name}"><Key>'
+ "".join(f'<PropertyRef Name="{k}"/>' for k in key) + "</Key>"
+ "".join(props)
+ "".join(f'<NavigationProperty Name="{n}" Relationship="{namespace}.{a}" '
f'FromRole="From_{a}" ToRole="To_{a}"/>' for n, a in navs)
+ "</EntityType>")
for name, src, dst, many in assocs:
body.append(f'<Association Name="{name}"><End Type="{namespace}.{src}" Multiplicity="1" '
f'Role="From_{name}"/><End Type="{namespace}.{dst}" Multiplicity="{many}" '
f'Role="To_{name}"/></Association>')
body.append('<EntityContainer Name="' + namespace + '_Entities">'
+ "".join(f'<EntitySet Name="{t[0][:-4]}" EntityType="{namespace}.{t[0]}"/>' for t in types)
+ "</EntityContainer>")
return ('<?xml version="1.0" encoding="utf-8"?>'
'<edmx:Edmx Version="1.0" xmlns:edmx="http://schemas.microsoft.com/ado/2007/06/edmx" '
'xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata" '
'xmlns:sap="http://www.sap.com/Protocols/SAPData"><edmx:DataServices m:DataServiceVersion="2.0">'
f'<Schema Namespace="{namespace}" xmlns="http://schemas.microsoft.com/ado/2008/09/edm">'
+ "".join(body) + "</Schema></edmx:DataServices></edmx:Edmx>")
SAMPLE_METADATA = {
"sales": _metadata("API_SALES_ORDER_SRV", [
("A_SalesOrderType", ["SalesOrder"], [
_prop("SalesOrder", "String", "Sales Order", max_len="10"),
_prop("SalesOrderType", "String", "Sales Order Type", max_len="4"),
_prop("SalesOrganization", "String", "Sales Organization", max_len="4"),
_prop("SoldToParty", "String", "Sold-to Party", max_len="10"),
_prop("CreationDate", "DateTime", "Created On", "Record Created On"),
_prop("RequestedDeliveryDate", "DateTime", "Req. Deliv. Date", "Requested Delivery Date"),
_prop("TotalNetAmount", "Decimal", "Net Value",
"Net Value of the Sales Order in Document Currency", unit="TransactionCurrency"),
_prop("TransactionCurrency", "String", "Doc. Currency", "SD Document Currency", "5"),
_prop("DeliveryBlockReason", "String", "Delivery Block", "Delivery Block (Document Header)", "2"),
_prop("HeaderBillingBlockReason", "String", "Billing Block", "Billing Block in SD Document", "2"),
_prop("TotalCreditCheckStatus", "String", "Credit Status", "Overall Status of Credit Checks", "1"),
], [("to_Item", "assoc_Item"), ("to_Partner", "assoc_Partner")]),
("A_SalesOrderItemType", ["SalesOrder", "SalesOrderItem"], [
_prop("SalesOrder", "String", "Sales Order", max_len="10"),
_prop("SalesOrderItem", "String", "Item", "Sales Order Item", "6"),
_prop("Material", "String", "Material", "Material Number", "40"),
_prop("SalesOrderItemText", "String", "Item Description", "Short Text for Sales Order Item", "40"),
_prop("RequestedQuantity", "Decimal", "Requested Qty", "Requested Quantity",
unit="RequestedQuantityUnit"),
_prop("RequestedQuantityUnit", "String", "Unit", "Unit of the Requested Quantity", "3"),
_prop("NetAmount", "Decimal", "Net Value",
"Net Value of the Order Item in Document Currency", unit="TransactionCurrency"),
_prop("TransactionCurrency", "String", "Doc. Currency", "SD Document Currency", "5"),
_prop("ItemBillingBlockReason", "String", "Billing Block", "Billing Block for Item", "2"),
_prop("DeliveryStatus", "String", "Delivery Status", "Delivery Status (Item)", "1"),
], [("to_SalesOrder", "assoc_ItemHeader")]),
("A_SalesOrderHeaderPartnerType", ["SalesOrder", "PartnerFunction"], [
_prop("SalesOrder", "String", "Sales Order", max_len="10"),
_prop("PartnerFunction", "String", "Partner Function", max_len="2"),
_prop("Customer", "String", "Customer", "Customer Number", "10"),
], []),
], [("assoc_Item", "A_SalesOrderType", "A_SalesOrderItemType", "*"),
("assoc_Partner", "A_SalesOrderType", "A_SalesOrderHeaderPartnerType", "*"),
("assoc_ItemHeader", "A_SalesOrderItemType", "A_SalesOrderType", "1")]),
"bp": _metadata("API_BUSINESS_PARTNER", [
("A_CustomerType", ["Customer"], [
_prop("Customer", "String", "Customer", "Customer Number", "10"),
_prop("CustomerName", "String", "Name of Customer", max_len="80"),
_prop("OrderIsBlockedForCustomer", "String", "Order Block", "Central Order Block for Customer", "2"),
_prop("DeliveryIsBlocked", "String", "Delivery block", "Central delivery block for the customer", "2"),
_prop("BillingIsBlockedForCustomer", "String", "Billing Block", "Central Billing Block for Customer", "2"),
], []),
("A_BusinessPartnerType", ["BusinessPartner"], [
_prop("BusinessPartner", "String", "Business Partner", "Business Partner Number", "10"),
_prop("Customer", "String", "Customer", "Customer Number", "10"),
'<Property Name="BusinessPartnerFullName" Type="Edm.String" MaxLength="81"/>',
_prop("BusinessPartnerCategory", "String", "BP Category", "Business Partner Category", "1"),
_prop("BusinessPartnerIsBlocked", "Boolean", "Central Block", "Central Block for Business Partner"),
], [("to_Customer", "assoc_BPCustomer")]),
], [("assoc_BPCustomer", "A_BusinessPartnerType", "A_CustomerType", "0..1")]),
}
def _order(no, party, created, amount, dblock="", bblock="", credit=""):
return {"SalesOrder": no, "SalesOrderType": "OR", "SalesOrganization": "1710", "SoldToParty": party,
"CreationDate": f"/Date({created})/", "RequestedDeliveryDate": f"/Date({created + 1209600000})/",
"TotalNetAmount": amount, "TransactionCurrency": "USD", "DeliveryBlockReason": dblock,
"HeaderBillingBlockReason": bblock, "TotalCreditCheckStatus": credit}
def _item(order, no, material, text, qty, amount, bblock=""):
return {"SalesOrder": order, "SalesOrderItem": no, "Material": material, "SalesOrderItemText": text,
"RequestedQuantity": qty, "RequestedQuantityUnit": "PC", "NetAmount": amount,
"TransactionCurrency": "USD", "ItemBillingBlockReason": bblock, "DeliveryStatus": "A"}
SAMPLE_SETS = {
"sales": {"A_SalesOrder": [
_order("9000003", "CUST-C", 1789171200000, "5100.00"),
_order("9000004", "CUST-A", 1789257600000, "7300.00", dblock="01", credit="B"),
_order("9000005", "CUST-D", 1789344000000, "12000.00"),
]},
"bp": {
"A_Customer": [
{"Customer": "CUST-A", "CustomerName": "Northwind Pumps Inc.", "OrderIsBlockedForCustomer": "",
"DeliveryIsBlocked": "", "BillingIsBlockedForCustomer": ""},
{"Customer": "CUST-C", "CustomerName": "Contoso Valves", "OrderIsBlockedForCustomer": "",
"DeliveryIsBlocked": "", "BillingIsBlockedForCustomer": ""},
],
"A_BusinessPartner": [
{"BusinessPartner": "1000042", "Customer": "CUST-A", "BusinessPartnerFullName": "Northwind Pumps Inc.",
"BusinessPartnerCategory": "2", "BusinessPartnerIsBlocked": False},
],
},
}
SAMPLE_KEYS = {"A_SalesOrder": "SalesOrder", "A_Customer": "Customer", "A_BusinessPartner": "BusinessPartner"}
SAMPLE_ITEMS = {
"9000004": [_item("9000004", "10", "VALVE-40", "Control valve", "4", "6800.00"),
_item("9000004", "20", "SEAL-7", "Seal kit", "10", "500.00")],
}
SAMPLE_PARTNERS = {
"9000004": [{"SalesOrder": "9000004", "PartnerFunction": "SP", "Customer": "CUST-A"},
{"SalesOrder": "9000004", "PartnerFunction": "SH", "Customer": "CUST-A"}],
}
if __name__ == "__main__":
main()
In the terminal (still in unit01, with (.venv) showing), run:
python data_model_trace.py --sample
On macOS or Linux, use python3 if python is not found.
You should see this:
Target: sample data on your computer (made up, shaped like SAP's APIs)
A. Read both contracts ($metadata)
A_SalesOrder key SalesOrder 11 fields, 2 navigation properties
A_SalesOrderItem key SalesOrder+SalesOrderItem 10 fields, 1 navigation properties
A_SalesOrderHeaderPartner key SalesOrder+PartnerFunction 3 fields, 0 navigation properties
A_Customer key Customer 5 fields, 0 navigation properties
A_BusinessPartner key BusinessPartner 5 fields, 1 navigation properties
A_SalesOrder --to_Item--> A_SalesOrderItem (many)
A_SalesOrder --to_Partner--> A_SalesOrderHeaderPartner (many)
B. Find the order (header)
Scanned 3 orders; with a delivery or billing block: 1
Order 9000004: sold-to CUST-A, 7300.00 USD, delivery block '01', billing block ''
C. Follow to_Item (what was ordered)
item 10: 4 PC VALVE-40 (Control valve)
item 20: 10 PC SEAL-7 (Seal kit)
D. Follow to_Partner (who is involved)
partner function SP: customer CUST-A
partner function SH: customer CUST-A
E. Cross into the Business Partner API (the sold-to customer)
A_Customer CUST-A: Northwind Pumps Inc.; central blocks: order '', delivery '', billing ''
A_BusinessPartner 1000042: Northwind Pumps Inc., category 2, centrally blocked: False
F. Save the trace and the data dictionary
Wrote blocked_order_trace.json (2 items, 2 partners)
Wrote data_dictionary.md (89 lines, 4855 characters)
First rows of the A_SalesOrder table in the dictionary:
| SalesOrder | Sales Order | String(10) | Key. |
| SalesOrderType | Sales Order Type | String(4) | A code defined in configuration. |
| SalesOrganization | Sales Organization | String(4) | An organizational unit key defined in configuration. |
| SoldToParty | Sold-to Party | String(10) | |
Read it from the top:
A read two contracts and checked that every field in the plan exists. It found each entity set's key, and resolved to_Item and to_Partner through their associations to many records.
B scanned three orders and picked the one with a delivery block. The block is shown as the raw code 01.
C and D followed the two navigation properties. The partner functions SP and SH are made-up sample values; your system returns its own codes.
E crossed into the second API by matching the sold-to customer number. The customer has no central blocks, so the block sits on the order alone.
Open data_dictionary.md in VS Code. Right-click it and choose Open Preview to see the tables rendered. It has a section on how the records connect, three reading rules, and one table per entity set.
A valid "empty" result: if no order in the slice has a block, the script says so and traces the first order instead. If the sold-to customer has no business partner pointing to it, step E says so; that is a real data finding worth raising, not a script error.
Check that .env in your orchestrate-course folder has a line SAP_API_KEY= followed by your key from the hub (Show API Key after you log on).
Run:
python data_model_trace.py
The structure of the output is the same; the numbers, names and codes come from SAP's shared demo data. Step A now shows the real field counts, which are much larger than in the sample.
If the first 100 orders have no block, scan more: python data_model_trace.py --scan 500. Or pick an order you saw on the hub: python data_model_trace.py --order <number>.
One plan, checked against the contract. If SAP renames or drops a field, step A fails with the field's name before any data is read.
API names in the output. The trace keeps SoldToParty, not a renamed customer. The dictionary explains the names, and anyone can trace a value back to the API.
Rules beside the metadata. SAP's text says what a field is. Your NOTES say how to read it. Keep both in one generated file so they can't drift apart.
Developers building on S/4HANA itself, with ABAP Cloud, read data through released CDS views such as I_SalesOrder rather than tables. ABAP Cloud and RAP for AI builders covers that model. To find a released view:
The Cloudification Repository on GitHub, with JSON and CSV files per product (SAP Cloud ERP public cloud, private cloud releases) and a viewer. It shows successors for objects that are not released.
The ABAP development tools' Released Objects tree, for developers with a system.
The SAP Business Accelerator Hub, where released CDS views are a content type of their own, next to APIs.
Side-by-side applications, including Python AI services, read through APIs such as API_SALES_ORDER_SRV and API_BUSINESS_PARTNER. In a company's S/4HANA Cloud system, an administrator opens each one through a communication arrangement, as described in Calling your first SAP API. That topic also covers SAP's April 2026 API policy and what it says about AI agents.
The SAP Cloud SDK team publishes generated models of these APIs (for example @sap/cloud-sdk-vdm-sales-order-service, version 2.1.0). They list entity sets, keys, fields with lengths and descriptions, and navigation properties. They are handy for reading, but they reflect the API version they were generated from. Your system's $metadata is the authority.
For analytics and AI across many objects and systems, SAP's answer is data products in SAP Business Data Cloud, with business meaning described in SAP Knowledge Graph. SAP Knowledge Graph and Business Data Cloud explains both. The thinking in this topic still applies: know which object you read, and write down what its fields mean.
Using APIs and views is subject to your SAP contract and SAP's API policy. This topic makes no licensing claims; ask your SAP account team which use is covered.
Authorizations. An API call runs as a communication user, with the access its arrangement allows. In S/4HANA, CDS access controls filter rows by the user's authorizations when the data is read inside the system; a copy outside does not carry them. Grounding on SAP data with authorizations and Agent permissions and SAP authorizations cover the design.
Personal data. Business partner records can hold the names and addresses of people, not only companies. That is personal data: read it only when the use case needs it. See Data security and PII.
Codes from configuration. Replace "look it up" with real text from the customer's own configuration, signed off by the process owner, never from a guess or another system.
Change. Re-run the trace after every upgrade and configuration change. The plan check (step A) catches removed fields; review the dictionary for changed meanings.
Volume. A trace of one order is cheap. A nightly job over 50,000 orders needs paging, $select and the batch design from Calling your first SAP API.
Prompt size and evaluation. Put only the dictionary sections a task needs into the prompt. Test that the model refuses to explain unknown codes; Unit 8 shows how.
Looking up entity types by entity set name. In real metadata A_Customer is an entity set; its type is A_CustomerType. Go through the entity set.
Treating a label as an explanation. "Billing Block" names the field. It doesn't explain the code inside it.
Mixing levels with the same field name.OrderIsBlockedForCustomer exists on the central customer record and on the sales-area record, with different meanings. A dictionary row needs its entity set.
Assuming customer number equals business partner number. Look one up from the other.
Trusting similar view names.I_PurchaseOrderAPI01 is released; in the private cloud list, I_PurchaseOrder is not.
Reading "journal lines" without saying which view. Entry view and ledger view answer different questions.
Dumping every field into the prompt. More fields mean more tokens, more personal data and more room for wrong reasoning.
Add one field to your trace and prove the dictionary still covers everything. The output, a trace plus a complete dictionary, is the grounding context for the prompts you write in Unit 5.
Before you start: finish Steps 1 to 4 above.
Open data_model_trace.py. In PLAN, find the list of fields for A_SalesOrderItem.
Add "DeliveryStatus" at the end of that list, after "ItemBillingBlockReason". Keep the comma between items.
In NOTES, add this line just above the "PartnerFunction" line:
"DeliveryStatus": "A status code. Look up its values for your release; don't guess.",
Save, then run:
python data_model_trace.py --sample
The screen output looks the same as before, except that step F now reports data_dictionary.md (90 lines, 4978 characters): the sample metadata already describes DeliveryStatus, so step A passes. The new field shows up in the two files. Open data_dictionary.md and find the DeliveryStatus row in the A_SalesOrderItem table. If the run stops with has no field DeliveryStatus, check the spelling.
In the unit01 folder, create check_dictionary.py with this content and save it:
"""Check that every field in blocked_order_trace.json is explained in data_dictionary.md."""
import json
from pathlib import Path
here = Path(__file__).parent
trace = json.loads((here / "blocked_order_trace.json").read_text(encoding="utf-8"))
dictionary = (here / "data_dictionary.md").read_text(encoding="utf-8")
records = [trace["sales_order"], *trace["items"], *trace["partners"],
trace["sold_to"]["customer"], trace["sold_to"]["business_partner"] or {}]
fields = sorted({name for record in records for name in record})
missing = [name for name in fields if f"| {name} |" not in dictionary]
print(f"Fields in the trace: {len(fields)}")
print("Missing from the dictionary:", ", ".join(missing) if missing else "none")
Run it:
python check_dictionary.py
You should see:
Fields in the trace: 29
Missing from the dictionary: none
Commit both files with Git (git add, then git commit -m "Trace a blocked order and its data dictionary"), as in Git and GitHub for AI engineers.
Done whendata_dictionary.md has a DeliveryStatus row with your note, and check_dictionary.py prints Missing from the dictionary: none.
Pick one answer for each question. The explanation appears after you choose.
1Your script looks up A_Customer in the list of entity types from real SAP metadata and finds nothing. Why?
Answer: A. In SAP's real metadata, entity sets point to entity types whose names usually end in Type. The script must map the entity set to its type before reading keys and properties.
2How does the script know that to_Item leads to many items rather than one?
Answer: D. In OData V2 metadata a navigation property points to an association and a role. The association's end gives the target type and its multiplicity, where * means many.
3Why does the dictionary prefer sap:quickinfo over sap:label when both exist?
Answer: A. SAP defines sap:label as a short caption and sap:quickinfo as tooltip text. Real labels can be as terse as "Ord.blk:sls ar.", so the quickinfo explains more to a model.
4A colleague wants the assistant to read sales orders from table VBAK because "it has everything". What do you propose instead?
Answer: D. SAP's Cloudification Repository marks VBAK "not to be released" and names I_SalesDocument as its successor. Outside S/4HANA the released route is the API; consumption views serve one app and are not a general-purpose source.
5The trace shows DeliveryBlockReason: "01". What should the data dictionary say about it?
Answer: C. Block reasons are configured per company, so neither the metadata nor a demo system tells you what 01 means. Marking it as a code that needs looking up stops a model from guessing.
6How does the script get from the sales order to the customer master record?
Answer: D. No navigation property crosses from the Sales Order API into the Business Partner API. The script joins by key: the sold-to party's customer number is the key of A_Customer.
7After an upgrade, step A stops with "A_SalesOrderItem has no field NetAmount". What is the right response?
Answer: C. The early check exists to catch contract changes before bad data flows into prompts. Fix the plan against the current metadata and review the dictionary; tables are internal and carry no stability promise.
8Finance asks for "journal lines" for an anomaly model. What should you clarify first?
Answer: C. SAP keeps both the Universal Journal (ACDOCA, the ledger view) and the entry view (BSEG). They answer different questions, so a design names which one it reads and why.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
Related topics
Picked from the terms, links and tags this topic shares with others.
Cloudification Repository: SAP Cloud ERP released and classic API list (SAP on GitHub)— released state of I_SalesDocument, I_SalesOrder, I_SalesOrderPartner, I_PurchaseOrderAPI01, I_JournalEntry, I_JournalEntryItem, I_Customer, I_BusinessPartner; tables VBAK, VBAP, EKKO, EKPO, BKPF, BSEG, ACDOCA, KNA1, BUT000 marked not to be released, with their successors (files of 16 October 2026)