Reporting and analytics architecture: what lives where in an SAP landscape
Decide where each report belongs (transactional system, data platform, group reporting or API) and design cross-system margin reporting that reconciles.
Every SAP program promises "reporting". The hard part is deciding where each report should run.
Some reports belong inside the transactional system. A buyer's list of late purchase orders or a planner's list of shortages must show what is true right now, and it acts on single records. S/4HANA has its own analytics for that, built on the same data the processes write.
Other reports need data from several systems, years of history, or a plan to compare against. Those belong on a separate data platform that copies, cleans and combines data from the sources. SAP's offering for that today is SAP Business Data Cloud, with SAP Datasphere for modeling and SAP Analytics Cloud for dashboards and planning. Many companies also run a non-SAP data warehouse.
Two more homes are easy to miss. Legal consolidation has its own product and rules. And a "report" that an outside party checks one order at a time is really an app or API, not analytics.
This topic follows Calder Pumpworks, the fictional pump maker this unit uses. Its CFO wants margin by pump model across its US company on S/4HANA and its German company on ECC.
The wrong home makes reports slow, stale or wrong. A shortage list built in a data warehouse that loads overnight shows yesterday's shortages. A five-year margin trend run on the transactional system competes with order entry for the same database.
Cross-system numbers rarely agree by themselves. Calder's two companies keep different currencies, different profitability models and different customer numbers for each other. Someone has to define how they combine. If nobody does, two spreadsheets give two margins, and meetings argue about the numbers instead of the business.
Copies cost money and need governing. Every copy of SAP data has to be loaded, secured, reconciled and paid for. A data platform is worth it when a question truly needs one. It is waste when the transactional system can already answer.
Contracts and SAP's rules shape the choices. How data may leave an SAP system is not only technical. As of mid-2026, SAP restricts customer and third-party use of one common extraction interface (the RFC modules of its ODP data replication API) and points customers to SAP Business Data Cloud and ODP over OData instead (SAPinsider on SAP Note 3255746). A feed into a non-SAP warehouse needs that checked before it is built.
AI depends on it. An AI assistant that answers "what was our margin on ES-300 pumps?" is only as good as the governed figure it reads. The analytics layer is often the safest place to ground it, because the numbers there are already defined, reconciled and filtered by role.
As of October 2026, SAP's analytics offerings split along the same line as this topic.
Need
What SAP offers
Note
Operational reports on one S/4HANA system
SAP S/4HANA embedded analytics: a virtual data model of core data services (CDS) views, analytical queries on top, and Fiori analytical apps
SAP Analytics Cloud is the engine behind embedded stories, through a live connection (SAP Learning)
Key users building their own reports
Custom CDS Views and Custom Analytical Queries apps; Manage KPIs and Reports
No coding; built in development and transported (SAP Learning)
Data from many systems, history, data science
SAP Business Data Cloud: foundation services that build data products, SAP Datasphere for models and access control, SAP Databricks, SAP BW private cloud edition
SAP Analytics Cloud: stories, planning, and Just Ask
The key front end of Business Data Cloud (SAP Learning)
An existing SAP BW
SAP BW/4HANA, with maintenance planned to 2040; BW 7.5 maintenance to 2030 (SAP's training page says mainstream; SAP's 2020 announcement splits it into mainstream to end 2027 and optional extended maintenance to 2030, so confirm on SAP's release strategy page)
Can feed Business Data Cloud through a Data Product Generator (SAP Learning)
A non-SAP platform is also a valid home. The Business Data Cloud topic in Unit 7 explains data products and how BDC Connect shares them with partner platforms such as Databricks and Snowflake without another copy.
Calder's design workshop listed eight reporting needs. Each is placed by a few questions: how many systems, how fresh, how much history, and who reads it.
flowchart LR
S4[(S/4HANA CP10)] --> EA[Embedded analytics]
ECC[(ECC CP20)] --> ER[ECC reports]
S4 --> EL[(Enterprise<br/>data layer)]
ECC --> EL
MES[(Shop-floor<br/>system)] --> EL
EL --> BI[Dashboards<br/>and planning]
S4 --> GR[Group reporting]
ECC -->|upload| GR
S4 --> API[API for<br/>distributors]
ID
Need
Where it lives
Why
RP01
Blocked sales orders worklist
Embedded analytics, CP10
One system, must be live, users act on each order
RP02
MRP exceptions for plant CP11
Embedded analytics, CP10
Same
RP03
Augsburg's open purchase orders
ECC report, until CP20 joins S/4HANA
One system; rebuilding it elsewhere is wasted after July 2027
RP04
Group margin by pump model
Enterprise data layer
Two systems, two currencies, three years of history
RP05
Plan vs actual, with next year's plan
Enterprise data layer and planning
Needs plan entry next to actuals from both companies
RP06
Test bench first-pass yield
Enterprise data layer
Combines the shop-floor system with S/4HANA
RP07
Order status for distributors
API or app (not reporting)
One record at a time, for outside users
RP08
Consolidated statements
Group reporting
Legal rules, eliminations, audit trail
RP04 shows why history matters. Under Calder's migration plan, only open items move from ECC; closed history stays behind (Data migration). After July 2027, a three-year margin trend needs a layer that holds both the old ECC figures and the new S/4HANA ones.
"Put everything in the data warehouse." Worklists need live data and links back to the transaction. A nightly copy shows yesterday's problems.
"Embedded analytics can do group reporting." It reports on one system. Combining companies on two systems, with currency translation and eliminations, needs another layer.
"Business Data Cloud is just Datasphere renamed." Datasphere is one component. Business Data Cloud also includes SAP Analytics Cloud, SAP Databricks, SAP BW private cloud edition and SAP-managed data products.
"Any tool can pull SAP data the same way." Some extraction interfaces are now restricted for non-SAP use. Check before you build.
"If the totals look right, the report is right." Reconcile to control totals from each source, and check that every source line landed in a bucket.
"AI makes the semantic layer unnecessary." AI that reads raw tables guesses what "margin" means. A governed model tells it.
Pick one answer for each question. The explanation appears after you choose.
1Rockford's planners need a list of today's MRP exceptions to act on. Where should it live?
Answer: D. Planners act on each exception now, in the system that holds it. A nightly copy shows yesterday's exceptions, and group reporting is for legal consolidation.
2Why does Calder's "group margin by pump model" need a separate data layer?
Answer: A. The report joins Rockford's S/4HANA margin with Augsburg's ECC figures, translates currency, and keeps history from before the move. Embedded analytics reports on one system's live data.
3A distributor wants to check the status of its orders online. What is the right design?
Answer: A. Looking up one order at a time, for an outside party, is an operational access need, not analytics. An app or API can return live status and enforce that each distributor sees only its own orders.
4Your team plans to feed a non-SAP data warehouse straight from ECC. What should you check first?
Answer: A. As of 2026, SAP restricts customer and third-party use of the RFC modules of its ODP replication API and points to Business Data Cloud and ODP over OData. Checking that early avoids building a feed that later stops.
5October's margin file counted Augsburg's spare-parts sales as pump margin. Which control would have caught it?
Answer: D. Reconciliation checks that each source line lands in a defined bucket and that totals match the source. A wrong mapping shows up there; a reader looking at a dashboard rarely spots it.
6Why is the analytics layer often a safe place to ground an AI assistant on SAP data?
Answer: A. An assistant that reads governed measures answers with an agreed definition and the reader's own access rights. Reading raw tables, it has to guess what "margin" means. The layer is usually not live, which is a limit to state.
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.
A reporting landscape is a set of homes, and every report needs exactly one.
Each home trades something. The transactional system gives live data and one click back to the document, but it sees only itself and is busy running the business. A data platform sees every system and keeps history, but it holds copies that are minutes to a day old and must be reconciled. Group reporting applies legal consolidation rules that no dashboard should reinvent. An API serves one record at a time to whoever needs it.
So the design work is not choosing a tool. It is writing down, for every report, four facts (systems, freshness, history, readers) and letting those facts pick the home. Then, for the reports that combine systems, define the measures once, in one place, and prove every figure back to its source.
Three consequences follow:
Placement is a rule, not a taste. Write the rule down so the next report is placed the same way.
A cross-system measure is a design object. "Group margin" needs a definition, a currency rule, a mapping of every source line and a reconciliation. Without them, two teams compute two margins.
Copies are liabilities. Each one needs a load, a security model, a reconciliation and an owner. Make one only when a question needs it.
SAP's training describes three layers: the SAP HANA database holds the data, a virtual data model (VDM) of CDS views sits in the middle, and Fiori analytical apps sit on top. CDS views are the VDM's building blocks, and customers can copy or extend the delivered ones or build their own (SAP Learning). The SAP data model topic in Unit 1 covers CDS views and the VDM in more depth.
An analytical query is a special kind of CDS view. It selects fields, sets filters, and adds calculations and hierarchies, and it can be consumed by SAP Analytics Cloud or SAP BusinessObjects as well as Fiori apps (SAP Learning). On top of the queries, SAP's training lists the report types a key user can create with Manage KPIs and Reports: KPIs, generic drilldown reports, analytical list pages, Data Analyzer reports, multidimensional reports, review booklets and SAP Analytics Cloud stories (SAP Learning).
flowchart BT
DB[(SAP HANA tables)] --> V[CDS views<br/>virtual data model]
V --> Q[Analytical queries]
Q --> A[Fiori analytical apps,<br/>KPIs, stories]
A -->|navigate| T[The business document]
Two tool tracks build these objects. Key users use View Browser, Custom CDS Views, Query Browser and Custom Analytical Queries (SAP Learning). Developers on on-premise systems use ABAP Development Tools in Eclipse (SAP Learning). Either way, custom objects are built in the development system and transported, like any other change (SAP Learning).
What embedded analytics is good at: live worklists and KPIs on one system, where the user acts on what they see. What it is not built for: joining another company's ECC data, keeping history from a retired system, or entering a plan.
A data platform needs a supported way to copy data out. For S/4HANA, the strategic route is CDS-based extraction. A CDS view annotated @Analytics.dataExtraction.enabled becomes available to the operational data provisioning (ODP) framework, in the context the post names ODP_CDS (SAP's BW training writes ABAP_CDS), with delta based on a timestamp or on change data capture (SAP Community). The same post lists SAP Analytics Cloud, SAP BW/4HANA, SAP BW, SAP Data Intelligence and SAP Datasphere as consumers, and draws an edition line: in S/4HANA Cloud Public Edition only views SAP has enabled for extraction can be used, while on-premise systems allow custom extraction views and should restrict the connection to C1-released views.
For ECC, the older route is the classic extractors: function-module-based DataSources on the service API (S-API), which CDS extraction replaces in S/4HANA (SAP Learning). Table replication is the other route: an SAP blog shows a Datasphere replication flow reading ECC tables through SAP Landscape Transformation Replication Server (SLT), with an on-premise Cloud Connector and matching DMIS add-on versions on both sides (SAP Community). Raw tables carry no business meaning, so the model has to rebuild it.
Which interface a non-SAP tool may use is a contract question too. SAP Note 3255746 restricts the use of the RFC modules of the ODP data replication API by customer or third-party applications. Per SAPinsider's July 2026 summary, version 11 of the note appeared on 21 April 2026, a technical block was planned for June 2026 with a temporary opt-out to the end of 2026, and SAP points to SAP Business Data Cloud and ODP over OData for non-SAP targets (SAPinsider). Read the note's current version on the SAP Support Portal before you design a feed; the details have changed several times.
SAP's training describes Business Data Cloud as a set of components (SAP Learning):
Component
Role, as SAP describes it
Foundation services
Replicate data from SAP applications, harmonize it and turn it into data products
SAP Datasphere
Models on top of data products; analytical roles and data access control
SAP Databricks
Data engineering, AI and machine learning on SAP data
SAP BW, private cloud edition
Brings existing BW data in, so it can generate data products
SAP Analytics Cloud
Stories, planning, ad-hoc analysis, and AI-driven chat and suggestions
BDC cockpit
Browse and install data products and intelligent applications
SAP's BW/4HANA training calls Datasphere SAP's enterprise data fabric for public cloud and the central modeling component of Business Data Cloud. It says Business Data Cloud can technically run without Datasphere, but advises against it (SAP Learning). For customers with BW, the same lesson describes a Data Product Generator that turns BW InfoProviders into data products, and gives BW 7.5 maintenance to 2030 and BW/4HANA planned to 2040. Check the terms (mainstream or extended, and the dates) on SAP's release strategy page before you plan a BW retirement.
Calder's group margin by pump model shows what "combine two systems" really involves. The pieces:
Question
Calder's answer (course design)
What is margin in CP10?
Billed revenue minus cost of goods sold at standard, production variances and freight, from margin analysis in S/4HANA
What does CP20 contribute to a pump?
The impeller. Rockford's pump cost includes it at the transfer price; Augsburg's own margin on that impeller is the group's extra margin
How is CP20's figure attributed to a model?
Through the bill of materials: one CP-IMP200 per ES-200, one CP-IMP300 per ES-300, times the pumps issued
Which currency and rate?
USD, at the October average rate 1.0820, the same rate consolidation uses for income
What about lines that aren't pump components?
Every CP20 line maps to exactly one bucket; spare parts sold outside the group are reported separately; an unmapped line fails the check
What is left to consolidation?
Profit still in Rockford's stock (impellers not yet in a sold pump), which group reporting eliminates
Two system differences sit under that table. S/4HANA's margin analysis, which SAP's training says replaces the account-based profitability analysis of SAP ERP, groups revenue and cost by G/L account in universal journal storage. Costing-based profitability analysis keeps value fields in its own storage and isn't available in the cloud editions (SAP Learning). Calder's ECC uses costing-based (a course assumption), so CP20's figures arrive in a different shape and must be mapped, not just appended.
The attribution uses this month's impeller margin for the impellers in this month's pumps. Some of those impellers were bought last month at last month's rate. That is a simplification the controllers accepted and wrote down. A definition that nobody writes down is the one that causes the next argument.
The analytics layer is often the safest place to ground an AI assistant on SAP data, for three reasons:
Defined measures. "Group margin" has one definition, so the answer doesn't depend on how the model guesses a join.
Reconciled figures. The assistant can say whether the data passed its checks, and warn when it didn't.
Access already designed. In Business Data Cloud, Datasphere carries analytical roles and data access control (SAP Learning). SAP Analytics Cloud's Just Ask answers natural-language questions on models (SAP Learning).
Its limits matter as much:
Not live. A data layer loaded nightly can't answer "is order 1000002 still blocked?" That question belongs to the transactional system or an API.
Only what is modeled. If margin by customer isn't in the model, the right answer is "not available", not a guess from raw tables.
Not an action channel. Reading the analytics layer is safe; changing SAP data goes through released APIs with approval, as Grounding on SAP data with authorizations explains.
The natural-language queries topic in Unit 7 builds the text-to-query step; this topic's script shows the guardrail around it: answer from governed metrics, or decline with a reason.
You will write one script that does a reporting architect's four jobs for Calder. It places each of Calder's eight reporting needs in a home, by rule. It builds group margin by pump model from two made-up extracts, one from S/4HANA and one from ECC. It reconciles that model to the sources. And it answers questions only from the governed figures, declining the rest.
flowchart LR
P[calder_pack/<br/>company codes,<br/>products] --> S[reporting.py]
X[CP10 and CP20<br/>extracts in the script] --> S
S --> L[Placement]
S --> M[Group margin]
S --> C[Reconciliation]
S --> A[Governed answers]
S --> W[calder_reporting.md]
The script connects to no SAP system. It imitates the design decisions and checks on data small enough to read.
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
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
The script reads org_units.json to confirm that CP10 runs on S/4HANA and CP20 on ECC, and products.json for the pumps' standard costs (2,850.00 USD for the ES-200, 4,100.00 USD for the ES-300).
In VS Code, create a new file, paste the script below, and save it as reporting.py inside the unit1b folder.
"""Place Calder Pumpworks' reporting needs in the right layer, build group margin
by pump model across CP10 (S/4HANA) and CP20 (ECC), reconcile it, and answer
questions only from the governed figures.
Calder Pumpworks is a fictional company. Every requirement, record, amount, rate
and rule below is a course label or workshop input, not an SAP standard value or
a real project figure. Column names in the two extracts are course labels, not
SAP field names. The script reads the Calder sample pack (unit1b/calder_pack/)
and needs no account, no key and no network.
Run it from your course folder:
python unit1b/reporting.py where each reporting need should live
python unit1b/reporting.py --margin group margin by pump model, October 2026
python unit1b/reporting.py --check reconcile the margin model to the sources
python unit1b/reporting.py --ask "..." answer a question from governed metrics only
python unit1b/reporting.py --write write unit1b/calder_reporting.md
"""
import argparse
import json
import sys
from pathlib import Path
PACK = Path("unit1b") / "calder_pack"
OUT = Path("unit1b") / "calder_reporting.md"
# ------------------------------------------------------- reporting needs (fictional)
# systems: where the data comes from. latency: how fresh it must be.
# history: months of history the users need. Values are workshop inputs.
NEEDS = [
{"id": "RP01", "name": "Blocked sales orders worklist", "users": "Order-to-cash team, Rockford",
"systems": {"S4"}, "latency": "seconds", "history": 0},
{"id": "RP02", "name": "MRP exceptions for plant CP11", "users": "Planners, Rockford",
"systems": {"S4"}, "latency": "seconds", "history": 0},
{"id": "RP03", "name": "Open purchase orders by supplier, Augsburg", "users": "Buyers, Augsburg",
"systems": {"ECC"}, "latency": "seconds", "history": 0},
{"id": "RP04", "name": "Group margin by pump model", "users": "CFO, controllers, product managers",
"systems": {"S4", "ECC"}, "latency": "monthly", "history": 36},
{"id": "RP05", "name": "Plan vs actual by pump model, with next year's plan", "users": "Controllers, sales leads",
"systems": {"S4", "ECC"}, "latency": "monthly", "history": 24, "planning": True},
{"id": "RP06", "name": "Test bench first-pass yield", "users": "Plant manager, quality",
"systems": {"S4", "MES"}, "latency": "daily", "history": 12},
{"id": "RP07", "name": "Order status for distributors such as Hallam", "users": "Distributors (outside Calder)",
"systems": {"S4"}, "latency": "seconds", "history": 0, "single_record": True},
{"id": "RP08", "name": "Consolidated financial statements", "users": "Group accounting, auditors",
"systems": {"S4", "ECC"}, "latency": "monthly", "history": 24, "statutory": True},
]
LAYERS = {
"group": "Group reporting (consolidation)",
"api": "Not reporting: an API or app",
"enterprise": "Enterprise layer (data platform)",
"embedded": "Embedded analytics in S/4HANA (CP10)",
"ecc": "Report in ECC until CP20 joins S/4HANA",
}
def place(need: dict) -> tuple:
"""Return (layer key, reason). Rules are checked in order; the first match wins."""
if need.get("statutory"):
return "group", "legal consolidation with eliminations and audit trail"
if need.get("single_record"):
return "api", "one record at a time for an outside user, not analysis"
if len(need["systems"]) > 1:
return "enterprise", "combines " + " + ".join(sorted(need["systems"]))
if need.get("planning"):
return "enterprise", "needs plan data entry next to actuals"
if need["history"] > 24:
return "enterprise", f"needs {need['history']} months of history"
if need["systems"] == {"S4"}:
return "embedded", f"one system, {need['latency']} fresh, on live data"
if need["systems"] == {"ECC"}:
return "ecc", "one system, on live data; ECC has no S/4HANA analytics model"
return "enterprise", "source outside SAP"
# --------------------------------------------- margin model inputs (fictional)
RATE_AVERAGE = 1.0820 # USD per EUR, October 2026 average (course rate)
# CP10, from S/4HANA margin analysis, October 2026, USD. Same values as the
# record-to-report topic: units billed and issued, variances and freight.
CP10_MARGIN = {
"CP-ES200": {"billed": 16, "issued": 17, "variance": 388.00, "freight": 2100.00},
"CP-ES300": {"billed": 5, "issued": 5, "variance": 4280.00, "freight": 1320.00},
}
PRICE = {"CP-ES200": 4200.00, "CP-ES300": 6150.00} # net price per pump, USD
CP10_CONTROL_REVENUE = 97950.00 # S/4HANA control total, pumps, USD
# The impeller each pump uses, and the price CP10 pays CP20 for it (in the
# pump's standard cost). Course values.
BOM_IMPELLER = {"CP-ES200": ("CP-IMP200", 335.00), "CP-ES300": ("CP-IMP300", 455.00)}
# CP20, from ECC profitability analysis, October 2026, EUR. Revenue is booked at
# each invoice's own rate. Column names are course labels.
CP20_LINES = [
{"customer": "10000010", "material": "CP-IMP200", "qty": 80, "revenue_eur": 24769.14, "cogs_eur": 24800.00},
{"customer": "10000010", "material": "CP-IMP300", "qty": 6, "revenue_eur": 2523.10, "cogs_eur": 2370.00},
{"customer": "10000004", "material": "CP-IMP200", "qty": 12, "revenue_eur": 4920.00, "cogs_eur": 3720.00},
{"customer": "10000008", "material": "CP-CAST300", "qty": 2, "revenue_eur": 1180.00, "cogs_eur": 960.00},
]
CP20_CONTROL_REVENUE = 33392.24 # ECC control total, all lines, EUR
INTERCOMPANY_CUSTOMER = "10000010" # CP10 as a customer of CP20
# Which bucket each CP20 line belongs to in the group view. A line with no
# mapping is an error the check reports, never a silent drop.
MAPPING = {
("10000010", "CP-IMP200"): "component for ES-200",
("10000010", "CP-IMP300"): "component for ES-300",
("10000004", "CP-IMP200"): "CP20 spare parts",
}
METRICS = {
"group margin": "CP10 margin plus CP20's margin on the impellers in the pumps issued, in USD at the October average rate",
"cp10 margin": "Billed revenue minus cost of goods sold at standard, production variances and freight, CP10 only",
}
# ---------------------------------------------------------------- helpers
def money(x: float) -> str:
return f"{x:,.2f}"
def load_pack() -> dict:
try:
org = json.loads((PACK / "org_units.json").read_text(encoding="utf-8"))
products = json.loads((PACK / "products.json").read_text(encoding="utf-8"))
except FileNotFoundError:
sys.exit(f"Cannot read {PACK}. Run: python unit1b/make_calder_pack.py (Unit 1B setup).")
systems = {c["CompanyCode"]: c["System"] for c in org["CompanyCodes"]}
if systems != {"CP10": "S/4HANA", "CP20": "ECC"}:
sys.exit("The pack must hold CP10 on S/4HANA and CP20 on ECC. Run make_calder_pack.py again.")
std = {p["Product"]: p["to_Valuation"][0]["StandardPrice"] for p in products["Products"]}
for code in ("CP-ES200", "CP-ES300"):
if code not in std:
sys.exit(f"{code} is missing from products.json. Run make_calder_pack.py again.")
return std
def cp10_rows(std: dict) -> dict:
rows = {}
for model, m in CP10_MARGIN.items():
revenue = m["billed"] * PRICE[model]
cogs = m["issued"] * std[model]
margin = revenue - cogs - m["variance"] - m["freight"]
rows[model] = {"revenue": revenue, "cogs": cogs, "variance": m["variance"],
"freight": m["freight"], "margin": margin}
return rows
def cp20_unit_margin_usd(material: str) -> float:
"""CP20's margin per impeller sold to CP10 this month, translated to USD."""
lines = [l for l in CP20_LINES if l["customer"] == INTERCOMPANY_CUSTOMER and l["material"] == material]
qty = sum(l["qty"] for l in lines)
if qty == 0:
return 0.0
margin_eur = sum(l["revenue_eur"] - l["cogs_eur"] for l in lines) / qty
return margin_eur * RATE_AVERAGE
def group_rows(std: dict) -> dict:
rows = cp10_rows(std)
for model, row in rows.items():
material, _ = BOM_IMPELLER[model]
unit = cp20_unit_margin_usd(material)
row["impellers"] = CP10_MARGIN[model]["issued"]
row["cp20_unit"] = unit
row["cp20_margin"] = round(unit * row["impellers"], 2)
row["group_margin"] = row["margin"] + row["cp20_margin"]
return rows
def checks(std: dict) -> list:
"""Return a list of (ok, text). Every figure in the report must trace to a source."""
results = []
rev = sum(r["revenue"] for r in cp10_rows(std).values())
results.append((abs(rev - CP10_CONTROL_REVENUE) < 0.005,
f"CP10 revenue in the model {money(rev)} USD = S/4HANA control total {money(CP10_CONTROL_REVENUE)}"))
rev20 = sum(l["revenue_eur"] for l in CP20_LINES)
results.append((abs(rev20 - CP20_CONTROL_REVENUE) < 0.005,
f"CP20 revenue in the extract {money(rev20)} EUR = ECC control total {money(CP20_CONTROL_REVENUE)}"))
for l in CP20_LINES:
if (l["customer"], l["material"]) not in MAPPING:
results.append((False, f"CP20 line customer {l['customer']}, {l['material']}, "
f"{money(l['revenue_eur'])} EUR has no mapping: add it to MAPPING"))
for model, (material, _) in BOM_IMPELLER.items():
sold = any(l["customer"] == INTERCOMPANY_CUSTOMER and l["material"] == material for l in CP20_LINES)
results.append((sold, f"{model}: impeller {material} has CP20 sales to CP10 this month"))
results.append((True, f"Rate: October average {RATE_AVERAGE:.4f} USD per EUR, the same rate consolidation uses"))
return results
# ---------------------------------------------------------------- commands
def print_layers() -> None:
print("Where each reporting need should live (Calder Pumpworks, fictional)\n")
for n in NEEDS:
layer, reason = place(n)
print(f" {n['id']} {n['name']}")
print(f" -> {LAYERS[layer]}: {reason}")
counts = {}
for n in NEEDS:
key = place(n)[0]
counts[key] = counts.get(key, 0) + 1
print("\n " + ", ".join(f"{LAYERS[k]}: {v}" for k, v in counts.items()))
def print_margin(std: dict) -> None:
rows = group_rows(std)
print("Margin by pump model, October 2026, USD (fictional)\n")
print(f" {'Model':<9}{'Revenue':>11}{'CP10 margin':>13}{'%':>7}{'CP20 on impellers':>19}{'Group margin':>14}{'%':>7}")
for model, r in rows.items():
print(f" {model:<9}{money(r['revenue']):>11}{money(r['margin']):>13}{r['margin'] / r['revenue']:>7.1%}"
f"{money(r['cp20_margin']):>19}{money(r['group_margin']):>14}{r['group_margin'] / r['revenue']:>7.1%}")
print()
for model, r in rows.items():
material, price = BOM_IMPELLER[model]
print(f" {model}: {r['impellers']} impellers {material}, CP20 margin {money(r['cp20_unit'])} USD each "
f"(CP10 pays {money(price)} USD)")
spare = [l for l in CP20_LINES if MAPPING.get((l["customer"], l["material"])) == "CP20 spare parts"]
if spare:
print(f" CP20 spare parts sold outside the group: {money(sum(l['revenue_eur'] for l in spare))} EUR revenue, "
"reported separately, not in pump margin")
def print_check(std: dict) -> int:
print("Reconciliation of the margin model to its sources\n")
results = checks(std)
for ok, text in results:
print(f" {'OK ' if ok else 'FAIL'} {text}")
failed = sum(1 for ok, _ in results if not ok)
print(f"\n {failed} issue(s). " + ("The report may be published." if not failed
else "Fix them before anyone reads the figures."))
return 1 if failed else 0
def answer(question: str, std: dict) -> str:
"""Answer only from the governed metrics. Anything else is declined, with a reason."""
q = question.lower()
squeezed = q.replace("-", "").replace(" ", "")
models = [m for m in CP10_MARGIN if m.lower().replace("cp-", "") in squeezed]
out_of_scope = [("customer", "margin by customer isn't in the governed model yet"),
("forecast", "the model holds actuals only; forecasts belong to planning (RP05)"),
("next month", "the model holds actuals only; forecasts belong to planning (RP05)"),
("2025", "only October 2026 is loaded in this course model")]
for word, reason in out_of_scope:
if word in q:
return f"Not answered: {reason}."
metric = "cp10 margin" if ("cp10" in q or "rockford" in q) else "group margin" if "margin" in q else None
if metric is None or not models:
return ("Not answered: ask for 'group margin' or 'CP10 margin' and name a pump model "
"(ES-200 or ES-300). Those are the governed metrics.")
failed = [t for ok, t in checks(std) if not ok]
rows = group_rows(std)
lines = []
for m in models:
r = rows[m]
value = r["group_margin"] if metric == "group margin" else r["margin"]
lines.append(f"{m} {metric}, October 2026: {money(value)} USD ({value / r['revenue']:.1%} of revenue).")
lines.append(f"Definition: {METRICS[metric]}.")
lines.append("Source: CP10 margin analysis (S/4HANA) and CP20 profitability extract (ECC), course data.")
if failed:
lines.append(f"Warning: the model has {len(failed)} open reconciliation issue(s); run --check.")
return "\n".join(lines)
def write_report(std: dict) -> None:
rows = group_rows(std)
failed = [t for ok, t in checks(std) if not ok]
md = ["# Calder Pumpworks reporting landscape (fictional)", "",
"```mermaid", "flowchart LR",
" S4[(S/4HANA CP10)] --> EA[Embedded analytics<br/>RP01 RP02]",
" ECC[(ECC CP20)] --> ER[ECC reports<br/>RP03]",
" S4 --> EL[(Enterprise layer)]",
" ECC --> EL",
" MES[(MES)] --> EL",
" EL --> BI[Dashboards and planning<br/>RP04 RP05 RP06]",
" S4 --> GR[Group reporting<br/>RP08]",
" ECC -->|upload| GR",
" S4 --> API[API or app<br/>RP07]",
"```", "", "## Placement", "",
"| ID | Need | Layer | Reason |", "| --- | --- | --- | --- |"]
for n in NEEDS:
layer, reason = place(n)
md.append(f"| {n['id']} | {n['name']} | {LAYERS[layer]} | {reason} |")
md += ["", "## Group margin by pump model, October 2026, USD (synthetic)", "",
"| Model | Revenue | CP10 margin | CP20 on impellers | Group margin |", "| --- | --- | --- | --- | --- |"]
for m, r in rows.items():
md.append(f"| {m} | {money(r['revenue'])} | {money(r['margin'])} | {money(r['cp20_margin'])} | {money(r['group_margin'])} |")
md += ["", f"Reconciliation: {len(failed)} open issue(s).", "",
"## Decision record", "",
"- **Context:** fill in", "- **Options:** fill in", "- **Decision:** fill in",
"- **Consequences:** fill in", "", "All data is fictional."]
OUT.write_text("\n".join(md) + "\n", encoding="utf-8")
print(f"Wrote {OUT}. Open it and fill in the decision record.")
def main() -> None:
parser = argparse.ArgumentParser(description="Calder reporting landscape (fictional data)")
parser.add_argument("--margin", action="store_true", help="group margin by pump model")
parser.add_argument("--check", action="store_true", help="reconcile the margin model to its sources")
parser.add_argument("--ask", metavar="QUESTION", help="answer from governed metrics only")
parser.add_argument("--write", action="store_true", help="write unit1b/calder_reporting.md")
args = parser.parse_args()
std = load_pack()
if args.margin:
print_margin(std)
elif args.check:
sys.exit(print_check(std))
elif args.ask:
print(answer(args.ask, std))
elif args.write:
write_report(std)
else:
print_layers()
if __name__ == "__main__":
main()
Read the blocks NEEDS, CP10_MARGIN, BOM_IMPELLER, CP20_LINES and MAPPING first. They are Calder's reporting workshop and October data written as Python. Every value is invented. CP10's October figures are the same as in the record-to-report topic; the impeller price 335.00 USD and the 80 impellers invoiced in October come from the intercompany topic. The CP-IMP300 price and cost, the CP20 spare-parts lines, the control totals and the needs RP01 to RP08 are this topic's course values.
Part of the script
What it does
NEEDS
Calder's eight reporting needs with systems, freshness and history
place()
The placement rule: checked in order, the first match wins
CP10_MARGIN, PRICE
CP10's October margin inputs from S/4HANA, in USD
BOM_IMPELLER
Which impeller each pump uses, and what CP10 pays for it
CP20_LINES
CP20's October profitability lines from ECC, in EUR
MAPPING
Which bucket each CP20 line belongs to
group_rows()
CP10 margin plus CP20's margin on the impellers in the pumps issued
checks()
The five reconciliation checks
answer()
Answers from the governed metrics, or declines with a reason
write_report()
Writes the landscape diagram, placement, margin and a decision record template
Where each reporting need should live (Calder Pumpworks, fictional)
RP01 Blocked sales orders worklist
-> Embedded analytics in S/4HANA (CP10): one system, seconds fresh, on live data
RP02 MRP exceptions for plant CP11
-> Embedded analytics in S/4HANA (CP10): one system, seconds fresh, on live data
RP03 Open purchase orders by supplier, Augsburg
-> Report in ECC until CP20 joins S/4HANA: one system, on live data; ECC has no S/4HANA analytics model
RP04 Group margin by pump model
-> Enterprise layer (data platform): combines ECC + S4
RP05 Plan vs actual by pump model, with next year's plan
-> Enterprise layer (data platform): combines ECC + S4
RP06 Test bench first-pass yield
-> Enterprise layer (data platform): combines MES + S4
RP07 Order status for distributors such as Hallam
-> Not reporting: an API or app: one record at a time for an outside user, not analysis
RP08 Consolidated financial statements
-> Group reporting (consolidation): legal consolidation with eliminations and audit trail
Embedded analytics in S/4HANA (CP10): 2, Report in ECC until CP20 joins S/4HANA: 1, Enterprise layer (data platform): 3, Not reporting: an API or app: 1, Group reporting (consolidation): 1
Read the rule in place() from the top. Legal consolidation and one-record lookups are caught first, because they are never "analytics" whatever their data looks like. Next, anything combining systems goes to the enterprise layer. Only then does a single-system need stay home.
Margin by pump model, October 2026, USD (fictional)
Model Revenue CP10 margin % CP20 on impellers Group margin %
CP-ES200 67,200.00 16,262.00 24.2% -7.10 16,254.90 24.2%
CP-ES300 30,750.00 4,650.00 15.1% 138.05 4,788.05 15.6%
CP-ES200: 17 impellers CP-IMP200, CP20 margin -0.42 USD each (CP10 pays 335.00 USD)
CP-ES300: 5 impellers CP-IMP300, CP20 margin 27.61 USD each (CP10 pays 455.00 USD)
CP20 spare parts sold outside the group: 4,920.00 EUR revenue, reported separately, not in pump margin
The ES-200 line shows the group losing 0.42 USD on each impeller. That is Augsburg selling at 335.00 USD an impeller that costs it 310.00 EUR: at 1.0820, it earns nothing. The intercompany topic traced the same transfer price problem from the finance side. The ES-300 line shows the opposite: Augsburg earns 27.61 USD on each CP-IMP300, so the group margin is higher than Rockford's own. Neither number is visible in either system alone.
Reconciliation of the margin model to its sources
OK CP10 revenue in the model 97,950.00 USD = S/4HANA control total 97,950.00
OK CP20 revenue in the extract 33,392.24 EUR = ECC control total 33,392.24
FAIL CP20 line customer 10000008, CP-CAST300, 1,180.00 EUR has no mapping: add it to MAPPING
OK CP-ES200: impeller CP-IMP200 has CP20 sales to CP10 this month
OK CP-ES300: impeller CP-IMP300 has CP20 sales to CP10 this month
OK Rate: October average 1.0820 USD per EUR, the same rate consolidation uses
1 issue(s). Fix them before anyone reads the figures.
The exit code is 1. One CP20 line, two castings CP-CAST300 sold to a German customer, has no bucket. The control totals still match, which is the point: totals alone wouldn't have caught it. You fix it in the exercise.
python unit1b\reporting.py --ask "What is the group margin of the ES-200 in October?"
macOS / Linux:
python unit1b/reporting.py --ask "What is the group margin of the ES-200 in October?"
You should see:
CP-ES200 group margin, October 2026: 16,254.90 USD (24.2% of revenue).
Definition: CP10 margin plus CP20's margin on the impellers in the pumps issued, in USD at the October average rate.
Source: CP10 margin analysis (S/4HANA) and CP20 profitability extract (ECC), course data.
Warning: the model has 1 open reconciliation issue(s); run --check.
Now ask for something the model doesn't hold:
Windows (PowerShell):
python unit1b\reporting.py --ask "Which customer buys the most ES-300 pumps?"
macOS / Linux:
python unit1b/reporting.py --ask "Which customer buys the most ES-300 pumps?"
You should see:
Not answered: margin by customer isn't in the governed model yet.
The answer carries its definition, its source and the reconciliation status. The decline names why. answer() uses keyword rules so the script runs without an account; in a real assistant a language model turns the question into a request, and code like this decides whether the request is inside the governed model. The script has one local user, so it doesn't check who is asking. A real assistant must also check the asker's own authorizations (in Business Data Cloud, Datasphere's analytical roles and data access control) before it reads a figure, and run the question under that user, never a broad technical user.
#Step 8: Write the landscape file and save your work
Run:
Windows (PowerShell):
python unit1b\reporting.py --write
macOS / Linux:
python unit1b/reporting.py --write
You should see Wrote unit1b\calder_reporting.md. Open it and fill in the decision record. on Windows, or the same with / on macOS and Linux.
Open unit1b/calder_reporting.md in VS Code and press Ctrl+Shift+V (Windows, Linux) or Cmd+Shift+V (macOS) to see the diagram and tables.
Save your work with Git:
git add unit1b/reporting.py unit1b/calder_reporting.md
git commit -m "Unit 1B: Calder reporting landscape, group margin and reconciliation"
As of October 2026, the same design maps onto SAP's products like this:
Operational reporting, CP10: analytical queries on released CDS views, surfaced through Fiori apps and KPIs. Key users build them with Custom CDS Views and Custom Analytical Queries, developers with ABAP Development Tools; both are transported (SAP Learning). Stand-alone SAP Analytics Cloud stories that combine data from other SAP cloud solutions need an enterprise SAC license and an integration on SAP BTP, per the same page.
Enterprise layer:SAP Business Data Cloud, with Datasphere modeling the group margin on data products from CP10 and on custom loads from CP20 (SAP Learning). CP10 data leaves through extraction-enabled CDS views (SAP Community); CP20 through ECC extractors or SLT table replication (SAP Community).
Dashboards, planning and questions:SAP Analytics Cloud stories and planning on Datasphere models; Just Ask for natural-language questions (SAP Learning).
Existing BW: keep it and generate data products from it, or move to Datasphere; SAP's training describes both paths (SAP Learning).
Where it sits. The diagram from the foundational layer, with one design choice made explicit: CP20 reaches the enterprise layer by replication until July 2027, and then from CP10's S/4HANA system after the rollout. Its pre-rollout history stays in the data layer.
Non-functional requirements that decide the design
Requirement
Typical target (course values)
If missed
Freshness
Worklists live; margin daily; plan monthly
Users act on stale data, or wait for data they don't need sooner
History
36 months of margin, including ECC years
Trend breaks at go-live
Accuracy
0.00 difference to control totals; every line mapped
Two margins in one meeting
Data protection
Company-level and customer-level access in every layer
A US sales lead sees German margins
Load on the source
Extraction outside the busiest close days; delta, not full loads
Order entry slows down at month end
Cost
Only reports that need a copy get one
A platform paying to copy data nobody reads
Integration and identity.CP10 data leaves through released, extraction-enabled CDS views, with the connection user restricted to them. CP20 data leaves through SLT or ECC extractors under the same restriction; a direct feed from ECC to the non-SAP warehouse is checked against SAP Note 3255746 first (SAPinsider). Users reach embedded analytics with their S/4HANA roles (Roles and authorization design). In the data layer, their access is designed again in Datasphere's data access controls, keyed on company code and sales organization, because a copied row doesn't carry S/4HANA's authorization checks with it.
Context: The CFO needs group margin by pump model across CP10 (S/4HANA) and CP20 (ECC), with 36 months of history. CP20 joins CP10's system in July 2027 and its closed history stays in ECC. Calder already runs a non-SAP data warehouse for CRM and shop-floor data.
Options: (a) keep the spreadsheet; (b) build the model in the existing warehouse, fed from both SAP systems; (c) build it in Datasphere within Business Data Cloud, fed by CDS extraction from CP10 and SLT from CP20, and share it to the warehouse if needed.
Decision: (c), as a course choice. The SAP data stays on SAP's supported routes, the access model lives with the data, and the CP20 feed is temporary.
Consequences: A Business Data Cloud subscription to size and fund. Two feeds to run until July 2027, then one. A margin definition with a named owner in the controlling team. Shop-floor data still needs a route into the model for RP06.
What a design review will ask
Which reporting needs stay in S/4HANA, and what rule placed each of the others elsewhere?
How does each source's data leave, and is that interface permitted under SAP's current rules?
Who owns the definition of group margin, and where is it written?
How is the model reconciled to each source every period, and what happens when a check fails?
How are company-level access rules kept the same in S/4HANA and in the data layer?
Security and authorizations. In S/4HANA, CDS access controls filter rows by the user's authorizations when the data is read (Grounding on SAP data with authorizations). A copy loses that. Rebuild the rules in the data layer and test them with the same test users.
Released objects only. Build on released CDS views and extraction-enabled views. Unreleased tables change with upgrades and break feeds, which is the clean-core point from Fit-to-standard and extension choices.
Evaluation. Run the reconciliation every load, not once at go-live. Store the results so an auditor or an AI answer can show them.
Cost. Count the copies. Each one costs storage, compute, licensing and someone's time to reconcile. Decommission reports nobody opens.
Operations. Feeds fail quietly. Alert on late loads and failed checks, and show "data as of" on every dashboard.
Clean core. Analytical objects are built in development and transported (SAP Learning). Treat them like code: reviewed, tested, versioned.
Rebuilding every ECC report before the move. The fit-to-standard topic declined CP20's custom report ZMM_OPEN_PO_AGING because nobody ran it in 14 months. Ask who reads a report before migrating it.
Silent drops. A join that loses unmapped lines produces totals that look plausible. Fail on any unmapped line.
Mixed rates. Translating income at closing rates in one report and average rates in another produces two margins. Use the group's rate rules.
Treating a worklist as a dashboard. Moving the blocked-orders list to a nightly copy breaks the link to the order and shows yesterday's blocks.
Forgetting history at go-live. If closed ECC history isn't kept in the data layer, every trend starts again in July 2027.
Grounding AI on raw tables. An assistant that writes its own joins over raw SAP tables will define margin differently from the controllers. Point it at the governed model.
Assuming any extractor is allowed. Interface rules change; check SAP's current notes for every non-SAP feed.
Fix Calder's reconciliation issue, add a new reporting need, and record the decision.
Open unit1b/reporting.py. In MAPPING, add a line for the castings: ("10000008", "CP-CAST300"): "CP20 spare parts",.
Run python unit1b/reporting.py --check. It should report 0 issue(s) and end with exit code 0.
Run python unit1b/reporting.py --margin. The spare-parts line should now read 6,100.00 EUR revenue, and the pump margins should not change.
In NEEDS, add RP09: "Margin by customer across both companies", users "Sales leads", systems {"S4", "ECC"}, latency "monthly", history 24. Run python unit1b/reporting.py and confirm where it lands and why.
Run python unit1b/reporting.py --ask "What is the group margin of the ES-300?" and confirm the warning line is gone.
Run python unit1b/reporting.py --write, open unit1b/calder_reporting.md, and fill in the Decision record with your own context, options, decision and consequences for RP04. You may disagree with the course choice; say why.
Commit the script and the file.
Done when:--check reports 0 issue(s), --margin shows ES-200 group margin 16,254.90 USD and ES-300 4,788.05 USD with 6,100.00 EUR of CP20 spare parts, RP09 is placed in the enterprise data layer because it combines ECC and S/4HANA, and unit1b/calder_reporting.md has a filled-in decision record. Keep it: the capstone at the end of Unit 1B traces one exception across the enterprise, and this landscape is its reporting part.
Pick one answer for each question. The explanation appears after you choose.
1In the script, why does place() test for legal consolidation and one-record lookups before anything else?
Answer: D. Consolidation has legal rules and its own product, and a one-record lookup for an outside user is an API. Testing them first stops a cross-system rule from sending them to the data layer.
2The group margin for the ES-200 is 7.10 USD lower than Rockford's own margin. What causes it?
Answer: D. At 335.00 USD and a cost of 310.00 EUR, Augsburg's margin per impeller translates to minus 0.42 USD at 1.0820. Seventeen impellers in the pumps issued make minus 7.10 USD. The scrap variance belongs to the ES-300.
3The control totals match, but --check still fails. What does that tell you?
Answer: B. Totals prove that everything arrived; the mapping check proves that everything landed somewhere defined. The castings line was in the extract and the total, but no bucket claimed it.
4You need CP10 sales data in a data platform. Which route is SAP's strategic one for S/4HANA?
Answer: D. CDS views annotated for extraction are exposed to the ODP framework, with delta by timestamp or change data capture. Raw tables carry no released contract, and per-record API calls don't scale to analytics loads.
5Calder's sales leads want margin by customer through the AI assistant, but the model doesn't hold it. What should the assistant do?
Answer: D. A governed assistant answers from defined measures or declines. Writing its own joins would invent a margin definition nobody agreed. The right fix is to add the measure to the model.
6Why must company-level access be designed again in the data layer?
Answer: B. In S/4HANA, CDS access controls filter rows when the data is read. Once copied, the row is just data. Datasphere carries its own analytical roles and data access controls, which must be designed and tested to match.
7A design review asks why CP20's history is kept in the data layer. What is the strongest answer?
Answer: B. Calder's migration moves master data and open items, not closed history. A three-year margin trend needs the old ECC figures next to the new S/4HANA ones, and the data layer is the one place that holds both.
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.
Describing Embedded Analytics (SAP Learning, implementing SAP S/4HANA Cloud Public Edition)— analytics embedded in the same stack as the business data; SAP Analytics Cloud as engine through a live connection; HANA, virtual data model of CDS views, Fiori analytical apps; analytical queries feed Data Analyzer, Multidimensional Reports, Review Booklets and SAC stories; View Browser, Custom CDS Views, Query Browser, Custom Analytical Queries, Manage KPIs and Reports; stand-alone stories across SAP cloud solutions need an enterprise SAC license; custom objects built in development and transported
Exploring the Modeling Tools of SAP S/4HANA embedded analytics (SAP Learning)— VDM of ABAP CDS views as modeling layers; analytical queries as a special CDS view type, consumable in SAC or SAP BusinessObjects; Custom CDS View and Custom Analytical Queries apps for key users; ABAP Development Tools for on-premise developers; analysis on real-time data
CDS based data extraction, Part I: Overview (SAP Community, SAP blog)— @Analytics.dataExtraction.enabled makes a CDS view available to ODP; context ODP_CDS; delta by timestamp or change data capture; consumers SAC, BW/4HANA, BW, Data Intelligence, Datasphere; in S/4HANA Cloud only SAP-enabled extraction views; on-premise custom views allowed, restrict to C1-released views
Exploring the Architecture of SAP Business Data Cloud (SAP Learning)— foundation services replicate from SAP source applications and build data products; Datasphere provides models on data products, analytical roles and data access control; SAP Databricks; SAP BW private cloud edition; SAP Analytics Cloud for stories, planning, ad-hoc analysis and AI chat; BDC cockpit
Positioning SAP Datasphere and SAP Business Data Cloud (SAP Learning, SAP BW/4HANA)— Datasphere as SAP's enterprise data fabric for public cloud and the modeling core of BDC; BDC combines HANA Cloud, Datasphere, BW or BW/4HANA PCE, SAP Databricks, SAC; Data Product Generator for BW InfoProviders; BW 7.5 mainstream maintenance to 2030; BW/4HANA planned to 2040
SAP Note 3255746: the June 2026 deadline that could break your SAP data pipelines (SAPinsider, 2026-07-02)— note restricts use of the RFC modules of the ODP data replication API by customer or third-party applications; RFC protocol, table and CDS extraction, BAPIs still usable; SAP points to BDC and ODP OData; note version 11 on 2026-04-21; self-assessment note 3439624; technical block planned for June 2026; temporary opt-out to end of 2026