When a company moves to a new SAP system, its data has to move too. The new system starts empty. Customers, suppliers, materials, open orders, unpaid invoices, stock and account balances must all be in it before the first user logs on.
Data migration is the workstream that does this. It sounds like a copy job. It isn't. Old data is incomplete, duplicated and shaped for the old system. Each record must be cleaned, mapped to the new structure, loaded in the right order and then proved: the business checks that what arrived matches what left, and signs.
The work runs as rehearsals. A team loads a copy of the data into a test system, finds what breaks, fixes it at the source and loads again. Only when a rehearsal passes agreed exit criteria does the real load happen, over a planned weekend called the cutover.
This topic follows Calder Pumpworks, the fictional pump maker this unit uses, as it moves its German company CP20 off ECC and into the S/4HANA system that already runs its US company.
Go-live depends on it. Configuration can be finished and tested for months, yet go-live still fails if the opening balances don't reconcile or if half the open purchase orders didn't load. Migration is usually the last workstream to finish and the one that sets the go-live date.
Bad data stops processes on day one. A customer without payment terms can't be billed. A material without a unit conversion can't be received. A duplicate supplier gets paid twice. Each of these is cheap to fix in the old system before the move and expensive to fix in production after it.
Finance must sign. Opening balances in the new system become the starting point for the next close and the next audit. If receivables in the new system differ from the old system by one invoice, someone has to find it before the books open.
It is a one-way door. SAP's own product team notes that the SAP S/4HANA migration cockpit migrates each record once, does not keep systems in sync, and has no deletion function; test cycles use other clients or refreshed systems instead (SAP Community, SAP product management). Whatever loads into production stays there.
AI raises the bar. Every AI use case on SAP data inherits the migrated data. Duplicates and blank fields carried over at go-live become wrong answers later. The data readiness topic in Unit 7 starts from what migration leaves behind.
As of October 2026, SAP's main tool for loading data into a new or extended SAP S/4HANA system is the SAP S/4HANA migration cockpit, opened through the Migrate Your Data app. SAP's training says it is available in on-premise, private cloud and public cloud deployments, with functions and object templates that vary by deployment, and that it is embedded at no additional cost (SAP Learning).
Need
What SAP offers
Note
Load from files
Staging tables with a local schema: download an XML or CSV template per migration object, fill it, upload it
The standard approach; can be labor-intensive for very large datasets (SAP Learning)
Load large volumes from any source
Staging tables in a remote SAP HANA schema, filled by your own ETL code or SAP Data Services
More complex setup; SAP's training calls it efficient for high volumes (SAP Learning)
Move data straight from an SAP ERP system
Direct transfer over a remote function call (RFC), selected by company code
A limited set of objects; the rest go through staging tables (SAP Learning)
Adjust objects
Migration object modeler (transaction LTMOM): change selection and field mapping, add custom fields
SAP Master Data Governance consolidation: load, standardize, match, review, best record, activate
A separate product; SAP's training names initial load as a use case (SAP Learning)
The cockpit is for the initial load. It creates records; it does not update records that already exist (SAP Community FAQ). For a company joining a system that is already live, like Calder, that matters: records that exist must be extended, not created again.
A whole-system conversion of ECC to S/4HANA is a different path. It keeps the data in place and transforms it, so most of this topic's loading work doesn't apply; the landscape topic compares the paths.
Run the cutover plan to the hour on a production-like system
Mock 2 criteria, within the time window, business sign-off
Production load
Weekend of 2-4 July 2027
The real move
Signed reconciliation, go/no-go decision
The rule behind the order is simple: a record can only load once everything it points to exists. A purchase order points to a supplier and a product. An open invoice points to a customer. Account balances come last, because they must agree with everything loaded before them.
"Migration is an IT task." IT moves the records. Only the business can say which customer is a duplicate, which payment terms are right, and whether the balances are correct.
"We'll clean the data in the new system." Cleansing after go-live means cleaning in production, under time pressure, while processes run on the bad data.
"Bring all the history, just in case." SAP's product team notes that the migration cockpit doesn't transfer historical transactions; the one exception is a historical balance object. History multiplies volume and reconciliation work.
"One test load is enough." The first mock finds the problems. The second proves the fixes. The dress rehearsal proves the timing. Each answers a different question.
"If the record counts match, we're done." Counts can match while amounts differ. Reconcile counts, amounts and a sample of records, and test that a process runs on them.
"AI can clean the data for us." AI can suggest duplicates and mappings quickly. A named owner still decides each one, because merging two customers changes who gets invoiced.
Pick one answer for each question. The explanation appears after you choose.
1Why is data migration often the workstream that sets the go-live date?
Answer: C. Configuration can be tested for months, but nobody goes live until opening data is loaded and the business has signed the reconciliation. Migration usually finishes last, so it sets the date.
2Calder's first rehearsal failed 21 records. What is the right response?
Answer: A. Finding errors is the purpose of a first mock. Each error gets an owner and a fix, preferably in the old system, and the next mock proves the fix.
3Calder's German company joins a system that already holds some of its suppliers and materials. What does that change?
Answer: A. The migration cockpit creates records and doesn't update existing ones. Records already in the target are extended with new org levels, so the company doesn't end up with two versions of one supplier.
4Who should sign the reconciliation of open receivables?
Answer: C. Only the business can confirm that the receivables in the new system are the ones it is owed. A developer can prove that records loaded; the owner proves they are right.
5An AI tool flags two German customers as likely duplicates. What should happen next?
Answer: C. A merge changes who is invoiced and paid. A suggestion speeds up the review, but a named owner decides and the decision is logged so the next mock repeats it.
6Why does Calder plan a dress rehearsal after mock load 2 already passed?
Answer: A. Mock 2 proves the data is right. The dress rehearsal proves the whole cutover, with every step and hand-off, fits the weekend window with time left for a fallback.
7What is the main risk of migrating every year of history "just in case"?
Answer: A. Each year of history adds records to load and reconcile and stretches the cutover. Ask what the business actually reads, and keep the rest readable in the old system or an archive.
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 data migration is a factory you build, run many times, and then run once for real.
The factory has four stations: extract from the old system, transform (cleanse, map, renumber), load into the new system, and reconcile what arrived against what left. Each mock load runs the whole factory end to end on a recent copy of the data. Every defect found is fixed at the station that caused it, ideally in the source data, so the next run is cleaner. When a run passes the exit criteria at full volume and inside the time window, the factory runs once more, on production, over the cutover weekend.
Three consequences follow:
Rules, not edits. A fix typed by hand into a test system disappears with the next run. Every fix must be a rule, a mapping entry or a decision in a log, so it repeats.
Order is data. A record can only load once everything it references exists. The load sequence comes from the references, not from a project plan.
Proof is the product. The business doesn't sign "records loaded". It signs "these counts and amounts match the old system, and these exceptions were approved by these people".
A migration into S/4HANA moves three kinds of data:
Kind
Examples at Calder
Why it moves
Master data
Customers, suppliers, products, banks, info records
Every document needs it
Open items and open documents
Unpaid invoices (receivables, payables), open purchase orders, open sales orders
Business in flight must finish in the new system
Balances
Stock quantities and values, G/L account balances, fixed asset values
The books and the warehouse must open where the old ones closed
Closed history stays behind. SAP's product team states that the migration cockpit does not transfer historical transactions; the one exception is the historical balance object (FI - Historical balance) in the file or staging approach (SAP Community, SAP product management, SAP KBA 2853964). Calder's decision in the landscape topic follows this: master data and open items move, and ECC stays read-only for closed history until it is archived.
Org structure and configuration don't migrate either. Company code CP20, plant CP21, sales organization CPDE and purchasing organization PUDE are configured in the target and moved through the transport route, before any data loads.
The migration cockpit works in migration objects: one object per kind of data, each with its own template, fields and rules. SAP's training says predecessor objects must be migrated first (SAP Learning). Calder's objects, with names as SAP's support articles write them:
Object (cockpit name)
References
Source
Supplier, Supplier - extend existing record by new org levels
Three details in that table cause most first-mock failures:
Extend, don't create. The extend objects add a new company code, purchasing organization or sales area to a business partner that already exists. SAP's support article adds a condition: the target business partner must already have the customer role (FLCU00, FLCU01) or supplier role (FLVN00, FLVN01) (KBA 3011873).
Stock is a goods movement.Material inventory balance posts the initial stock with movement type 561, so a zero quantity can't be posted (KBA 2989410). Products with no stock simply have no stock record.
Units must convert. An open purchase order in a unit the material master can't convert fails in simulation (KBA 3065870).
Calder loads G/L balances last. That is a course design choice, not an SAP rule: the trial balance check then runs on the complete picture, after the subledger objects (payables, receivables, stock) have posted their own amounts. Every open item and stock load also posts against an offset account so each journal entry balances; once everything is loaded, the offsets must net to zero. The account numbers are configuration and vary by system.
flowchart LR
ECC[(ECC CP20)] -->|RFC, company code CP20| DT[Direct transfer]
ECC -->|export| X[XML or CSV<br/>templates]
ECC -->|ETL tool| R[(Remote SAP HANA<br/>staging schema)]
X --> L[(Local staging tables)]
DT --> MC[Migration cockpit:<br/>mapping, simulate, migrate]
L --> MC
R --> MC
MC --> S4[(S/4HANA<br/>CP10 + CP20)]
Direct transfer. The cockpit reads an SAP ERP system over an RFC connection, selects data by company code, and converts it to the S/4HANA data model as it loads (SAP Learning). Only a set of predefined objects is supported; others need staging tables (SAP Learning). SAP's product team described it in February 2022 as available since release 1909 for S/4HANA on-premise and private edition (SAP Community); the community FAQ dates it for Public Edition to release 2402 (SAP Community FAQ).
Staging tables, local schema. Download a template per object as XML or CSV, fill it, upload it. SAP's Public Edition training gives a 100 MB limit per file (SAP Learning).
Staging tables, remote schema. A separate SAP HANA database holds the staging tables, filled by your own ETL code or by SAP Data Services. Its setup is more complex, and SAP's training calls it efficient for high-volume migrations.
Whichever way the data arrives, the cockpit then runs the same steps, as SAP's training lists them: prepare the staging tables, process mapping tasks (value mapping from old to new values, and fixed values for defaults), simulate (optional; errors can show here or in the migrate step), migrate, and create a correction file for records that failed (SAP Learning). Each object's documentation names an app to validate the result.
ETL or conversion. All three ways are forms of extract, transform, load: the data leaves one system and enters another. A system conversion is the alternative: the ECC system itself becomes S/4HANA, and its data is transformed in place. Calder's option A, a company code rollout, is ETL. That is why Calder needs everything below.
Cleansing is cheapest in the source. A blank payment term fixed in ECC is fixed for every mock and for production. A fix in a transformation rule works too, but it hides the bad data from the people who keep using ECC until cutover.
Calder's findings fall into five kinds, each with its own decision:
Finding
Decision
Where it is fixed
Mandatory field blank (payment terms, tax number)
Fill it
Source (ECC), by the data owner
Duplicate inside the source (two Tamsfield records, same tax number and bank)
Merge into one; map the old number to the survivor
Key mapping
Record already in the target (Ridgeline, CP10's supplier)
Extend the existing business partner
Extend object, key mapping
Number already used in the target (German customer 10000001 vs Brennock)
Renumber
Key mapping
Inactive or obsolete (no activity for 24 months, flagged for deletion)
Exclude with a reason
Selection, and closing stale documents in ECC
Matching finds the duplicates. Exact matches on a tax number or bank account are strong evidence. Similar names are weak evidence: "Ridgeline Bearing Supply" and "Ridgeline Bearing Supply Europe GmbH" look alike but have different tax numbers, and may be different legal entities that must stay apart. SAP Master Data Governance runs this as a process for business partners and products: load, check, standardize, match with SAP HANA fuzzy search or smart data quality, match review by a person, calculate a best record, validate and activate (SAP Learning). A small migration can follow the same steps with a script and a review sheet.
Key mapping ties it together. Every old number maps to exactly one new number (itself, a new number, a merge survivor or an extended record), and every open item, purchase order and stock record follows that map. The key map is also the reconciliation backbone: without it, nobody can prove that invoice 95001109 belongs to the customer it belonged to in ECC.
A mock load runs the factory end to end on a fresh extract into a non-production system. Because the cockpit can't delete migrated records and production must never be used for practice, each mock needs a clean target: another client or a refreshed system, as SAP's product team recommends (SAP Community).
Exit criteria turn "it looks fine" into a decision. Calder's, as course rules:
No load errors in simulation and migration.
Every count reconciles: records in scope = loaded or mapped + excluded with an approved reason.
Every amount reconciles to 0.00: receivables, payables, stock value and open PO value equal the ECC control totals.
Every profile finding has an approved decision, by a named owner.
For the dress rehearsal only: the whole cutover fits the window with the fallback time intact.
Measure load times in every mock at full volume. The time a mock takes is the best predictor of the cutover timeline you will need.
Counts per object: in scope, loaded or mapped, excluded
Financial
Do the amounts match?
Receivables and payables totals vs ECC reconciliation accounts; stock value per product; trial balance; offsets net to zero
Functional
Does a process run on the data?
Post a goods receipt to a migrated PO; clear a migrated invoice with a test payment; run MRP for plant CP21
Control totals come from the source, produced by the business owner of each object from ECC reports, before the load. The migration team compares the target against them. The owner signs. Keep signed reconciliations with the program records; auditors will ask how the opening balances were proved.
Cutover is a timetable, written to the hour and rehearsed. Calder's plan, as course dates:
When (course plan)
Step
Owner
Mon 28 June 2027
Freeze master data changes in ECC CP20; final cleansing checks
Data owners
Wed 30 June, 18:00 CET
Last postings in ECC CP20; stop interfaces and EDI to CP10
Augsburg finance, integration lead
Thu 1 to Fri 2 July
June close in ECC; control totals produced and signed
Augsburg finance
Fri 2 July, 18:00
Extract; load master data, stock, open POs, open items
Migration team
Sat 3 July
Load G/L balances; technical and financial reconciliation
Migration team, group accounting
Sun 4 July, 12:00
Functional checks; go/no-go decision
Program lead, business owners
Sun 4 July, 18:00
Fallback deadline: if no-go, reopen ECC CP20 for Monday
Program lead
Mon 5 July 2027
Augsburg works in S/4HANA; hypercare starts
Everyone
Two features matter most. A go/no-go point with a real fallback: until users post in the new system, the old one can reopen. And cutover at a period boundary, so the closing balances of June are the opening balances of July.
You will write one script that does a migration lead's four jobs on a small, made-up extract of Calder's German company. It plans the load sequence from the objects' references. It profiles the extract for missing fields, inactive records and broken units. It suggests duplicates and field mappings for a person to approve. And it runs two mock loads: one on the raw extract, one after the approved decisions, each with a reconciliation and the exit criteria.
flowchart LR
P[calder_pack/<br/>target records] --> S[migration.py]
E[CP20 extract<br/>in the script] --> S
S --> PL[calder_migration_plan.md]
S --> PR[Profile findings]
S --> SG[Suggestions]
S --> M[calder_mock_1.md<br/>calder_mock_2.md]
The script loads nothing into a real SAP system. It imitates the checks a migration cockpit simulation runs, so you can see the method 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 business_partners.json and products.json from the pack to know which records the S/4HANA system already holds for CP10, such as Brennock Water Utility as customer 10000001 and Ridgeline Bearing Supply as supplier 20000003.
In VS Code, create a new file, paste the script below, and save it as migration.py inside the unit1b folder.
"""Plan, profile and rehearse the migration of Calder Pumpworks GmbH (CP20)
from ECC into the S/4HANA system that already runs CP10.
Calder Pumpworks is a fictional company. Every record, number, amount, rule
and exit criterion below is a course label or workshop input, not an SAP
standard value or a real project figure. Migration object names follow SAP's
migration cockpit; check the object list for your own target release.
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/migration.py object list and load sequence
python unit1b/migration.py --profile data quality profile of the CP20 extract
python unit1b/migration.py --suggest duplicate and field mapping suggestions
python unit1b/migration.py --mock 1 mock load with the raw extract
python unit1b/migration.py --mock 2 mock load after the approved decisions
It writes unit1b/calder_migration_plan.md or unit1b/calder_mock_<n>.md
and changes nothing else.
"""
import argparse
import copy
import json
import sys
from difflib import SequenceMatcher
from pathlib import Path
PACK = Path("unit1b") / "calder_pack"
OUT = Path("unit1b")
KEY_DATE = "2027-01-31" # extract date of mock load 1 (course label)
INACTIVE_SINCE = "2025-01-31" # course rule: no activity for 24 months = out of scope
SIMILAR = 0.80 # course rule: name similarity that triggers a review
# --------------------------------------------------------- the object list
# One row per migration object, in no particular order. The script works out
# the load sequence from the predecessors.
OBJECTS = [
{"id": "ORG", "name": "Org units for CP20 (configuration, transported, not migrated)",
"pred": [], "owner": "Solution architect", "check": "CP20, CP21, CPDE, PUDE, AU01 exist in S4Q"},
{"id": "SUP", "name": "Supplier / Supplier - extend existing record by new org levels",
"pred": ["ORG"], "owner": "Purchasing lead CP20", "check": "count by action; no tax number twice"},
{"id": "CUS", "name": "Customer / Customer - extend existing record by new org levels",
"pred": ["ORG"], "owner": "AR lead CP20", "check": "count by action; no tax number twice"},
{"id": "PRD", "name": "Product (new) and an extension for plant CP21 (existing)",
"pred": ["ORG"], "owner": "Materials planner CP21", "check": "count by action; base unit and conversions"},
{"id": "INV", "name": "Material inventory balance",
"pred": ["PRD"], "owner": "Plant controller CP21", "check": "quantity and value per product = ECC stock"},
{"id": "POS", "name": "Purchase order (only open PO)",
"pred": ["SUP", "PRD"], "owner": "Purchasing lead CP20", "check": "count and open value = ECC open POs"},
{"id": "APO", "name": "FI - Accounts payable open item",
"pred": ["SUP"], "owner": "AP lead CP20", "check": "total = ECC payables reconciliation account"},
{"id": "ARO", "name": "FI - Accounts receivable open item",
"pred": ["CUS"], "owner": "AR lead CP20", "check": "total = ECC receivables reconciliation account"},
{"id": "GLB", "name": "FI - G/L account balance and open/line item",
"pred": ["APO", "ARO", "INV"], "owner": "Group accounting", "check": "trial balance = ECC; offsets net to zero"},
]
# ------------------------------------------- the CP20 extract (ECC, fictional)
LEGACY = {
"customers": [
{"key": "10000001", "name": "Lindqvar Wasserwerke AG", "country": "DE", "tax": "TX-DE-118204",
"terms": "NT30", "last_activity": "2027-01-12"},
{"key": "10000003", "name": "Lindqvar Wasserwerke", "country": "DE", "tax": "TX-DE-118204",
"terms": "NT30", "last_activity": "2026-11-03"},
{"key": "10000004", "name": "Brenzhof Pumpentechnik GmbH", "country": "DE", "tax": "TX-DE-230871",
"terms": "", "last_activity": "2027-01-20"},
{"key": "10000005", "name": "Kestrel Anlagenbau GmbH", "country": "AT", "tax": "",
"terms": "NT45", "last_activity": "2026-12-18"},
{"key": "10000007", "name": "Ammerholt Filter KG", "country": "DE", "tax": "TX-DE-402215",
"terms": "NT30", "last_activity": "2023-08-30"},
{"key": "10000008", "name": "Ostmark Filtertechnik GmbH", "country": "DE", "tax": "TX-DE-517730",
"terms": "NT30", "last_activity": "2027-01-25"},
{"key": "10000010", "name": "Calder Pumpworks Inc. (intercompany)", "country": "US", "tax": "TX-US-CP10",
"terms": "NT30", "last_activity": "2027-01-29", "intercompany": True, "trading_partner": ""},
],
"suppliers": [
{"key": "20000002", "name": "Tamsfield Foundry Ltd", "country": "GB", "tax": "TX-GB-77310",
"bank": "BK-7001", "terms": "NT45", "last_activity": "2027-01-27"},
{"key": "20000004", "name": "Tamsfield Foundry Ltd.", "country": "GB", "tax": "TX-GB-77310",
"bank": "BK-7001", "terms": "NT30", "last_activity": "2026-12-02"},
{"key": "20000007", "name": "Ridgeline Bearing Supply", "country": "US", "tax": "TX-US-RBS01",
"bank": "BK-3303", "terms": "NT45", "last_activity": "2027-01-14"},
{"key": "20000008", "name": "Ridgeline Bearing Supply Europe GmbH", "country": "DE", "tax": "TX-DE-660912",
"bank": "BK-8808", "terms": "NT30", "last_activity": "2027-01-21"},
{"key": "20000009", "name": "Hollmer Werkzeugdienst GmbH", "country": "DE", "tax": "TX-DE-330145",
"bank": "BK-9009", "terms": "", "last_activity": "2027-01-18"},
{"key": "20000011", "name": "Petrak Stahlhandel GmbH", "country": "DE", "tax": "TX-DE-145566",
"bank": "BK-1111", "terms": "NT30", "last_activity": "2027-01-26"},
{"key": "20000012", "name": "Vennick Kuehlmittel GmbH", "country": "DE", "tax": "TX-DE-099812",
"bank": "BK-1212", "terms": "NT30", "last_activity": "2024-05-15"},
],
"products": [
{"key": "CP-IMP200", "text": "Machined impeller for ES-200", "unit": "EA", "conv": {}, "deletion_flag": False},
{"key": "CP-IMP300", "text": "Machined impeller for ES-300", "unit": "EA", "conv": {}, "deletion_flag": False},
{"key": "CP-CAST200", "text": "Impeller casting ES-200", "unit": "EA", "conv": {}, "deletion_flag": False},
{"key": "CP-CAST300", "text": "Impeller casting ES-300", "unit": "EA", "conv": {}, "deletion_flag": False},
{"key": "CP-BAR42", "text": "Bar stock 42CrMo4 80 mm", "unit": "KG", "conv": {}, "deletion_flag": False},
{"key": "CP-BAR42-OLD", "text": "Bar stock 42CrMo4 80 mm", "unit": "KG", "conv": {}, "deletion_flag": True},
{"key": "CP-BRG6310", "text": "Bearing 6310", "unit": "EA", "conv": {}, "deletion_flag": False},
{"key": "CP-CTOOL", "text": "Cutting coolant", "unit": "L", "conv": {}, "deletion_flag": False},
{"key": "CP-FIX-07", "text": "Fixture plate, old impeller line", "unit": "EA", "conv": {}, "deletion_flag": False},
],
"stock": [ # plant CP21, valuation at standard price in EUR
{"product": "CP-IMP200", "qty": 140, "price": 310.00},
{"product": "CP-IMP300", "qty": 22, "price": 402.00},
{"product": "CP-CAST200", "qty": 260, "price": 41.50},
{"product": "CP-CAST300", "qty": 85, "price": 58.00},
{"product": "CP-BAR42", "qty": 3200, "price": 2.85},
{"product": "CP-BRG6310", "qty": 75, "price": 18.40},
{"product": "CP-CTOOL", "qty": 120, "price": 4.10},
{"product": "CP-FIX-07", "qty": 0, "price": 95.00},
],
"purchase_orders": [ # ordered and received in the PO unit; price per PO unit in EUR
{"key": "4500001201", "supplier": "20000002", "product": "CP-CAST200", "unit": "EA",
"ordered": 200, "received": 120, "price": 41.50, "due": "2027-02-15"},
{"key": "4500001202", "supplier": "20000004", "product": "CP-CAST300", "unit": "EA",
"ordered": 60, "received": 0, "price": 58.00, "due": "2027-02-22"},
{"key": "4500001203", "supplier": "20000007", "product": "CP-BRG6310", "unit": "EA",
"ordered": 40, "received": 0, "price": 18.40, "due": "2027-02-10"},
{"key": "4500001204", "supplier": "20000011", "product": "CP-BAR42", "unit": "KG",
"ordered": 1500, "received": 1500, "price": 2.85, "due": "2027-01-08"},
{"key": "4500001205", "supplier": "20000011", "product": "CP-BAR42-OLD", "unit": "KG",
"ordered": 500, "received": 0, "price": 2.85, "due": "2027-03-01"},
{"key": "4500001206", "supplier": "20000009", "product": "CP-CTOOL", "unit": "KAN",
"ordered": 20, "received": 0, "price": 82.00, "due": "2027-02-05"},
{"key": "4500001207", "supplier": "20000012", "product": "CP-CTOOL", "unit": "L",
"ordered": 200, "received": 0, "price": 4.10, "due": "2024-06-30"},
],
"ar_items": [
{"key": "95001102", "customer": "10000010", "amount": 12480.00},
{"key": "95001107", "customer": "10000001", "amount": 8200.00},
{"key": "95001109", "customer": "10000003", "amount": 1150.00},
{"key": "95001111", "customer": "10000004", "amount": 4760.00},
{"key": "95001114", "customer": "10000008", "amount": 3310.00},
],
"ap_items": [
{"key": "51000771", "supplier": "20000002", "amount": 4980.00},
{"key": "51000774", "supplier": "20000004", "amount": 1740.00},
{"key": "51000780", "supplier": "20000007", "amount": 2215.00},
{"key": "51000783", "supplier": "20000009", "amount": 1090.00},
],
}
# Records the S/4HANA system already holds besides the pack's CP10 partners.
TARGET_EXTRA_PARTNERS = {"20000004": "Corvane Lifting Services", "20000005": "Larkspur Freight Lines",
"20000006": "Tallis Road Freight"}
TARGET_EXTRA_PRODUCTS = {"CP-IMP200", "CP-IMP300"} # CP10 buys both from CP20 today (plant CP11 only)
TARGET_TAX = {"20000003": "TX-US-RBS01"} # Ridgeline's tax number in CP10's records
# ------------------------------------------- decisions for mock load 2
# Each decision names who approved it. "where": source = fixed in ECC before
# extraction; map = handled in the mapping (key mapping or value mapping).
DECISIONS = [
("customers", "10000001", "renumber", {"to": "10000101"}, "map", "Master data lead"),
("customers", "10000003", "merge", {"into": "10000001"}, "map", "AR lead CP20"),
("customers", "10000004", "fix", {"terms": "NT30"}, "source", "AR lead CP20"),
("customers", "10000005", "fix", {"tax": "TX-AT-552019"}, "source", "AR lead CP20"),
("customers", "10000007", "exclude", {"reason": "no activity since 2023-08-30, no open items"}, "map", "AR lead CP20"),
("customers", "10000010", "fix", {"trading_partner": "CP10"}, "source", "Group accounting"),
("suppliers", "20000004", "merge", {"into": "20000002"}, "map", "Purchasing lead CP20"),
("suppliers", "20000007", "extend", {"into": "20000003"}, "map", "Master data lead"),
("suppliers", "20000008", "distinct", {"of": "20000003"}, "map", "Purchasing lead CP20"),
("suppliers", "20000009", "fix", {"terms": "NT45"}, "source", "AP lead CP20"),
("suppliers", "20000012", "exclude", {"reason": "no activity since 2024-05-15; stale PO closed in ECC"}, "map", "Purchasing lead CP20"),
("products", "CP-IMP200", "extend", {"into": "CP-IMP200"}, "map", "Materials planner CP21"),
("products", "CP-IMP300", "extend", {"into": "CP-IMP300"}, "map", "Materials planner CP21"),
("products", "CP-BAR42-OLD", "exclude", {"reason": "flagged for deletion; duplicate of CP-BAR42"}, "map", "Materials planner CP21"),
("products", "CP-FIX-07", "exclude", {"reason": "no stock, no movements, no open documents"}, "map", "Materials planner CP21"),
("products", "CP-CTOOL", "fix", {"conv": {"KAN": 20}}, "source", "Materials planner CP21"),
("stock", "CP-FIX-07", "exclude", {"reason": "zero quantity; product not migrated"}, "map", "Plant controller CP21"),
("purchase_orders", "4500001205", "fix", {"product": "CP-BAR42"}, "source", "Purchasing lead CP20"),
("purchase_orders", "4500001207", "close", {"reason": "due 2024-06-30, supplier inactive"}, "source", "Purchasing lead CP20"),
]
KEY_FIELD = {"stock": "product"}
def load_pack():
"""The partners and products the S/4HANA system already holds for CP10."""
try:
bps = json.loads((PACK / "business_partners.json").read_text(encoding="utf-8"))["BusinessPartners"]
products = json.loads((PACK / "products.json").read_text(encoding="utf-8"))["Products"]
except FileNotFoundError:
sys.exit("Sample pack not found. Run: python unit1b/make_calder_pack.py")
partners = dict(TARGET_EXTRA_PARTNERS)
for bp in bps:
role = bp.get("to_Customer") or bp.get("to_Supplier")
companies = role.get("to_CustomerCompany") or role.get("to_SupplierCompany")
if any(c["CompanyCode"] == "CP10" for c in companies):
partners[bp["BusinessPartner"]] = bp["BusinessPartnerFullName"]
target_products = {p["Product"] for p in products if any(pl["Plant"] == "CP11" for pl in p["to_Plant"])}
return partners, target_products | TARGET_EXTRA_PRODUCTS
def load_sequence():
"""Order the objects so that every object comes after its predecessors."""
done, order, todo = set(), [], list(OBJECTS)
while todo:
ready = [o for o in todo if all(p in done for p in o["pred"])]
if not ready:
sys.exit("The predecessors form a loop: " + ", ".join(o["id"] for o in todo))
for o in ready:
order.append(o)
done.add(o["id"])
todo.remove(o)
return order
def plan():
order = load_sequence()
print(f"Calder CP20 migration: {len(order)} objects in load order (course labels)\n")
print(f"{'#':>2} {'ID':4} {'Object':64} After")
for n, o in enumerate(order, 1):
print(f"{n:>2} {o['id']:4} {o['name'][:64]:64} {', '.join(o['pred']) or '-'}")
fence = "`" * 3
lines = ["# Calder CP20 migration plan", "", "Fictional course data. Object names follow the SAP migration cockpit.",
"", "| # | Object | After | Owner | Reconciliation check |", "| --- | --- | --- | --- | --- |"]
lines += [f"| {n} | {o['name']} | {', '.join(o['pred']) or '-'} | {o['owner']} | {o['check']} |"
for n, o in enumerate(order, 1)]
lines += ["", fence + "mermaid", "flowchart LR"]
lines += [f" {o['id']}[\"{o['id']}\"]" for o in order]
lines += [f" {p} --> {o['id']}" for o in order for p in o["pred"]]
lines += [fence, ""]
(OUT / "calder_migration_plan.md").write_text("\n".join(lines), encoding="utf-8")
print("\nWrote unit1b/calder_migration_plan.md")
def norm(name):
"""Lower case, no punctuation, no legal form, for name comparison."""
words = "".join(c if c.isalnum() else " " for c in name.lower()).split()
return " ".join(w for w in words if w not in {"gmbh", "ag", "kg", "ltd", "inc"})
def duplicate_pairs(records, existing_tax=None):
"""Pairs that share a tax number or have similar names, with a score."""
pairs = []
for i, a in enumerate(records):
for b in records[i + 1:]:
score = SequenceMatcher(None, norm(a["name"]), norm(b["name"])).ratio()
same_tax = bool(a["tax"]) and a["tax"] == b["tax"]
if same_tax or score >= SIMILAR:
pairs.append((a["key"], b["key"], round(score, 2), same_tax))
for a in records:
for key, tax in (existing_tax or {}).items():
if a["tax"] == tax:
pairs.append((a["key"], f"{key} (in S/4HANA)", 1.0, True))
return pairs
def profile(quiet=False):
"""Print the findings and return the records they are about."""
say = (lambda *a: None) if quiet else print
say(f"CP20 extract, key date {KEY_DATE}: data quality profile (fictional data)\n")
found = set()
for kind, fields in (("customers", ["name", "country", "tax", "terms"]),
("suppliers", ["name", "country", "tax", "bank", "terms"])):
rows = LEGACY[kind]
say(f"{kind}: {len(rows)} records")
for f in fields:
missing = [r["key"] for r in rows if not r.get(f)]
filled = 100 * (len(rows) - len(missing)) / len(rows)
found |= {(kind, k) for k in missing}
say(f" {'FINDING' if missing else 'OK':8} {f:8} {filled:5.1f}% filled"
+ (f" missing: {', '.join(missing)}" if missing else ""))
for r in rows:
if r["last_activity"] < INACTIVE_SINCE:
found.add((kind, r["key"]))
say(f" FINDING inactive {r['key']} {r['name']}: last activity {r['last_activity']}")
if r.get("intercompany") and not r.get("trading_partner"):
found.add((kind, r["key"]))
say(f" FINDING trading partner missing on intercompany partner {r['key']}")
products = LEGACY["products"]
say(f"products: {len(products)} records")
stock = {s["product"]: s["qty"] for s in LEGACY["stock"]}
for p in products:
if p["deletion_flag"]:
found.add(("products", p["key"]))
say(f" FINDING {p['key']} is flagged for deletion")
elif stock.get(p["key"], 0) == 0 and not any(po["product"] == p["key"] for po in LEGACY["purchase_orders"]):
found.add(("products", p["key"]))
say(f" FINDING {p['key']} has no stock and no purchase order")
pos = LEGACY["purchase_orders"]
say(f"purchase orders: {len(pos)} records")
for po in pos:
prod = next(p for p in products if p["key"] == po["product"])
if po["unit"] != prod["unit"] and po["unit"] not in prod["conv"]:
found.add(("products", prod["key"]))
say(f" FINDING {po['key']} ordered in {po['unit']}, product {prod['key']} has no conversion to {prod['unit']}")
if po["received"] < po["ordered"] and po["due"] < INACTIVE_SINCE:
found.add(("purchase_orders", po["key"]))
say(f" FINDING {po['key']} still open but due {po['due']}")
say(f"\n{len(found)} record(s) with findings. Each needs an owner's decision before mock load 2.")
return found
def suggest():
print("Suggestions only. A named owner approves or rejects each one.\n")
print("Possible duplicates (score = name similarity, 1.00 = identical after cleanup)")
for kind, existing in (("customers", None), ("suppliers", TARGET_TAX)):
for a, b, score, same_tax in duplicate_pairs(LEGACY[kind], existing):
basis = "same tax number" if same_tax else "similar name only"
print(f" {kind[:-1]:8} {a} ~ {b}: {score:.2f}, {basis}")
print("\nField mapping, Augsburg export header -> course target field")
glossary = {"name 1": "BusinessPartnerFullName", "ust-idnr": "TaxNumber", "land": "Country",
"zahlungsbedingung": "PaymentTerms", "abstimmkonto": "ReconciliationAccount",
"basismengeneinheit": "BaseUnit", "materialkurztext": "ProductDescription"}
targets = sorted(set(glossary.values()) | {"Currency", "SupplierName"})
for header in ["Name 1", "USt-IdNr.", "Land", "Zahlungsbed.", "Abstimmkonto", "Basis-ME", "Waehrung"]:
key = header.lower().rstrip(".")
best = max(glossary, key=lambda g: SequenceMatcher(None, key, g).ratio())
score = SequenceMatcher(None, key, best).ratio()
if score >= 0.6:
print(f" {header:13} -> {glossary[best]:24} {score:.2f}")
else:
near = max(targets, key=lambda t: SequenceMatcher(None, key, t.lower()).ratio())
print(f" {header:13} -> ?{'':23} {score:.2f} no confident match (nearest: {near}); ask the data owner")
def apply_decisions(data):
"""Return the extract after source fixes, and the key map for the load."""
data = copy.deepcopy(data)
keymap, excluded, log = {}, {}, []
for kind, key, action, args, where, approver in DECISIONS:
field = KEY_FIELD.get(kind, "key")
rec = next(r for r in data[kind] if r[field] == key)
if action == "fix":
for f, v in args.items():
rec[f] = {**rec[f], **v} if isinstance(v, dict) else v
elif action == "close":
rec["ordered"] = rec["received"]
elif action in ("merge", "extend"):
keymap[(kind, key)] = (action, args["into"])
elif action == "renumber":
keymap[(kind, key)] = ("renumber", args["to"])
elif action == "exclude":
excluded[(kind, key)] = args["reason"]
log.append(f"{kind[:-1] if kind != 'stock' else 'stock'} {key}: {action} ({where}), approved by {approver}")
return data, keymap, excluded, log
def control_totals(data):
"""What the ECC side reports for the extract: the numbers the business signs."""
open_po = [p for p in data["purchase_orders"] if p["ordered"] > p["received"]]
return {
"AR open items (EUR)": round(sum(i["amount"] for i in data["ar_items"]), 2),
"AP open items (EUR)": round(sum(i["amount"] for i in data["ap_items"]), 2),
"Stock value CP21 (EUR)": round(sum(s["qty"] * s["price"] for s in data["stock"]), 2),
"Open PO value (EUR)": round(sum((p["ordered"] - p["received"]) * p["price"] for p in open_po), 2),
}
def mock(n):
partners, target_products = load_pack()
data, keymap, excluded, log = (copy.deepcopy(LEGACY), {}, {}, []) if n == 1 else apply_decisions(LEGACY)
errors, loaded = [], {}
created_tax, extended = {}, set()
def resolve(kind, key):
"""Follow merges, extensions and new numbers to the record in S/4HANA."""
while (kind, key) in keymap:
key = keymap[(kind, key)][1]
return key
for kind in ("suppliers", "customers"): # partners, in load order
ok = 0
for r in data[kind]:
k = (kind, r["key"])
if k in excluded:
continue
action = keymap.get(k, ("create",))[0]
target = resolve(kind, r["key"])
if action == "merge":
ok += 1
continue
if action == "extend":
if target in partners:
extended.add(target)
ok += 1
else:
errors.append(f"{kind[:-1]} {r['key']}: record {target} to extend not found")
continue
problems = []
if not r.get("terms"):
problems.append("payment terms missing")
if not r.get("tax"):
problems.append("tax number missing")
if r.get("intercompany") and not r.get("trading_partner"):
problems.append("trading partner missing on intercompany partner")
if target in partners:
problems.append(f"number {target} already used by {partners[target]}")
if r.get("tax") and (r["tax"] in created_tax.values() or r["tax"] in TARGET_TAX.values()):
problems.append("tax number already exists (duplicate)")
if problems:
errors.append(f"{kind[:-1]} {r['key']}: " + "; ".join(problems))
else:
created_tax[target] = r["tax"]
ok += 1
loaded[kind] = ok
good_partners = set(created_tax) | extended # records this migration created or extended
good_products, ok = set(), 0
for p in data["products"]:
k = ("products", p["key"])
if k in excluded:
continue
action, _ = keymap.get(k, ("create", p["key"]))
if action == "create" and p["key"] in target_products:
errors.append(f"product {p['key']}: already exists; migration creates records, it doesn't update them")
continue
good_products.add(p["key"])
ok += 1
loaded["products"] = ok
stock_value, ok = 0.0, 0
for s in data["stock"]:
if ("stock", s["product"]) in excluded:
continue
if s["qty"] <= 0:
errors.append(f"stock {s['product']}: quantity 0 can't be posted as an initial stock entry")
elif s["product"] not in good_products:
errors.append(f"stock {s['product']}: product has no plant CP21 data in the target")
else:
stock_value += s["qty"] * s["price"]
ok += 1
loaded["stock"] = ok
po_value, ok, in_scope = 0.0, 0, 0
for po in data["purchase_orders"]:
if po["ordered"] <= po["received"]:
continue # not open: out of scope for "Purchase order (only open PO)"
in_scope += 1
prod = next(p for p in data["products"] if p["key"] == po["product"])
problems = []
if resolve("suppliers", po["supplier"]) not in good_partners:
problems.append(f"supplier {po['supplier']} not in target")
if po["product"] not in good_products:
problems.append(f"product {po['product']} not in target")
if po["unit"] != prod["unit"] and po["unit"] not in prod["conv"]:
problems.append(f"no conversion {po['unit']} -> {prod['unit']}")
if problems:
errors.append(f"purchase order {po['key']}: " + "; ".join(sorted(set(problems))))
else:
po_value += (po["ordered"] - po["received"]) * po["price"]
ok += 1
loaded["purchase_orders"] = ok
sums = {}
for kind, partner_kind, field in (("ar_items", "customers", "customer"), ("ap_items", "suppliers", "supplier")):
total, ok = 0.0, 0
for item in data[kind]:
target = resolve(partner_kind, item[field])
if target in good_partners:
total += item["amount"]
ok += 1
else:
errors.append(f"{kind[:2].upper()} item {item['key']}: {partner_kind[:-1]} {item[field]} not in target")
sums[kind], loaded[kind] = total, ok
target_totals = {"AR open items (EUR)": round(sums["ar_items"], 2),
"AP open items (EUR)": round(sums["ap_items"], 2),
"Stock value CP21 (EUR)": round(stock_value, 2),
"Open PO value (EUR)": round(po_value, 2)}
control = control_totals(data)
print(f"Mock load {n}, CP20 extract {KEY_DATE} (fictional data)\n")
if log:
print(f"Applied {len(log)} approved decisions")
for line in log:
print(" " + line)
print()
print("Counts: in scope, loaded or mapped, excluded with reason")
counts_ok = True
for kind in ("suppliers", "customers", "products", "stock", "purchase_orders", "ar_items", "ap_items"):
field = KEY_FIELD.get(kind, "key")
scope = in_scope if kind == "purchase_orders" else len(data[kind])
exc = sum(1 for r in data[kind] if (kind, r[field]) in excluded)
gap = scope - loaded[kind] - exc
counts_ok &= gap == 0
print(f" {kind:16} {scope:3} {loaded[kind]:4} {exc:4} " + ("OK" if gap == 0 else f"DIFF {gap}"))
print("\nAmounts: ECC control total vs loaded")
amounts_ok = True
for name, value in control.items():
diff = round(value - target_totals[name], 2)
amounts_ok &= diff == 0
print(f" {name:24} {value:12,.2f} {target_totals[name]:12,.2f} " + ("OK" if diff == 0 else f"DIFF {diff:,.2f}"))
if errors:
print(f"\nLoad errors ({len(errors)})")
for e in errors:
print(" " + e)
decided = {(kind, key) for kind, key, *_rest, approver in (DECISIONS if n == 2 else []) if approver}
undecided = sorted(profile(quiet=True) - decided)
criteria = [("No load errors", not errors), ("Every count reconciles", counts_ok),
("Every amount reconciles to 0.00", amounts_ok),
("Every profile finding has an approved decision", not undecided)]
print("\nExit criteria")
for text, ok in criteria:
print(f" {'PASS' if ok else 'FAIL'} {text}")
passed = all(ok for _, ok in criteria)
print("\nResult: " + ("ready for the dress rehearsal" if passed else "not ready; fix the errors and run the next mock"))
report = [f"# Calder CP20 mock load {n}", "", f"Fictional course data, extract {KEY_DATE}.", ""]
report += [f"- {'PASS' if ok else 'FAIL'}: {text}" for text, ok in criteria]
report += ["", "## Load errors", ""] + ([f"- {e}" for e in errors] or ["- none"]) + [""]
(OUT / f"calder_mock_{n}.md").write_text("\n".join(report), encoding="utf-8")
print(f"Wrote unit1b/calder_mock_{n}.md")
return 0 if passed else 1
def main():
parser = argparse.ArgumentParser(description="Calder CP20 data migration (fictional course data)")
parser.add_argument("--profile", action="store_true", help="data quality profile of the extract")
parser.add_argument("--suggest", action="store_true", help="duplicate and field mapping suggestions")
parser.add_argument("--mock", type=int, choices=[1, 2], help="run mock load 1 (raw) or 2 (after decisions)")
args = parser.parse_args()
if args.profile:
profile()
elif args.suggest:
suggest()
elif args.mock:
sys.exit(mock(args.mock))
else:
load_pack()
plan()
if __name__ == "__main__":
main()
Save the file (Ctrl+S on Windows and Linux, Cmd+S on macOS).
Calder CP20 migration: 9 objects in load order (course labels)
# ID Object After
1 ORG Org units for CP20 (configuration, transported, not migrated) -
2 SUP Supplier / Supplier - extend existing record by new org levels ORG
3 CUS Customer / Customer - extend existing record by new org levels ORG
4 PRD Product (new) and an extension for plant CP21 (existing) ORG
5 INV Material inventory balance PRD
6 POS Purchase order (only open PO) SUP, PRD
7 APO FI - Accounts payable open item SUP
8 ARO FI - Accounts receivable open item CUS
9 GLB FI - G/L account balance and open/line item APO, ARO, INV
Wrote unit1b/calder_migration_plan.md
Open unit1b/calder_migration_plan.md and press Ctrl+Shift+V (Windows, Linux) or Cmd+Shift+V (macOS) for the preview. You should see the object table and a diagram of the dependencies.
The order is computed, not typed. The script repeatedly takes every object whose predecessors are already placed. If you add an object whose predecessors form a loop, it stops and names them.
CP20 extract, key date 2027-01-31: data quality profile (fictional data)
customers: 7 records
OK name 100.0% filled
OK country 100.0% filled
FINDING tax 85.7% filled missing: 10000005
FINDING terms 85.7% filled missing: 10000004
FINDING inactive 10000007 Ammerholt Filter KG: last activity 2023-08-30
FINDING trading partner missing on intercompany partner 10000010
suppliers: 7 records
OK name 100.0% filled
OK country 100.0% filled
OK tax 100.0% filled
OK bank 100.0% filled
FINDING terms 85.7% filled missing: 20000009
FINDING inactive 20000012 Vennick Kuehlmittel GmbH: last activity 2024-05-15
products: 9 records
FINDING CP-BAR42-OLD is flagged for deletion
FINDING CP-FIX-07 has no stock and no purchase order
purchase orders: 7 records
FINDING 4500001206 ordered in KAN, product CP-CTOOL has no conversion to L
FINDING 4500001207 still open but due 2024-06-30
10 record(s) with findings. Each needs an owner's decision before mock load 2.
Each finding is a question for a person, not a fix for the script. The trading partner finding is the one the intercompany topic already raised: intercompany partners without a trading partner break the group's elimination.
Suggestions only. A named owner approves or rejects each one.
Possible duplicates (score = name similarity, 1.00 = identical after cleanup)
customer 10000001 ~ 10000003: 1.00, same tax number
supplier 20000002 ~ 20000004: 1.00, same tax number
supplier 20000007 ~ 20000008: 0.87, similar name only
supplier 20000007 ~ 20000003 (in S/4HANA): 1.00, same tax number
Field mapping, Augsburg export header -> course target field
Name 1 -> BusinessPartnerFullName 1.00
USt-IdNr. -> TaxNumber 1.00
Land -> Country 1.00
Zahlungsbed. -> PaymentTerms 0.79
Abstimmkonto -> ReconciliationAccount 1.00
Basis-ME -> ? 0.54 no confident match (nearest: BaseUnit); ask the data owner
Waehrung -> ? 0.40 no confident match (nearest: BaseUnit); ask the data owner
Read the duplicate list like a steward would. Three pairs share a tax number and are strong candidates. The pair 20000007 and 20000008 shares only a similar name; in the decisions, Calder's purchasing lead keeps them apart. The mapping list shows the limit of string similarity: Basis-ME (base unit) and Waehrung (currency) are obvious to a German speaker and invisible to the matcher. That gap is where a language model helps, and where its answer still goes to the data owner.
Windows (PowerShell):python unit1b\migration.py --mock 1
macOS / Linux:python unit1b/migration.py --mock 1
You should see:
Mock load 1, CP20 extract 2027-01-31 (fictional data)
Counts: in scope, loaded or mapped, excluded with reason
suppliers 7 4 0 DIFF 3
customers 7 3 0 DIFF 4
products 9 7 0 DIFF 2
stock 8 5 0 DIFF 3
purchase_orders 6 3 0 DIFF 3
ar_items 5 2 0 DIFF 3
ap_items 4 1 0 DIFF 3
Amounts: ECC control total vs loaded
AR open items (EUR) 29,900.00 4,460.00 DIFF 25,440.00
AP open items (EUR) 10,025.00 4,980.00 DIFF 5,045.00
Stock value CP21 (EUR) 78,956.00 26,712.00 DIFF 52,244.00
Open PO value (EUR) 11,421.00 5,565.00 DIFF 5,856.00
Load errors (21)
supplier 20000004: number 20000004 already used by Corvane Lifting Services; tax number already exists (duplicate)
supplier 20000007: tax number already exists (duplicate)
supplier 20000009: payment terms missing
customer 10000001: number 10000001 already used by Brennock Water Utility
customer 10000004: payment terms missing
customer 10000005: tax number missing
customer 10000010: trading partner missing on intercompany partner
product CP-IMP200: already exists; migration creates records, it doesn't update them
product CP-IMP300: already exists; migration creates records, it doesn't update them
stock CP-IMP200: product has no plant CP21 data in the target
stock CP-IMP300: product has no plant CP21 data in the target
stock CP-FIX-07: quantity 0 can't be posted as an initial stock entry
purchase order 4500001202: supplier 20000004 not in target
purchase order 4500001203: supplier 20000007 not in target
purchase order 4500001206: no conversion KAN -> L; supplier 20000009 not in target
AR item 95001102: customer 10000010 not in target
AR item 95001107: customer 10000001 not in target
AR item 95001111: customer 10000004 not in target
AP item 51000774: supplier 20000004 not in target
AP item 51000780: supplier 20000007 not in target
AP item 51000783: supplier 20000009 not in target
Exit criteria
FAIL No load errors
FAIL Every count reconciles
FAIL Every amount reconciles to 0.00
FAIL Every profile finding has an approved decision
Result: not ready; fix the errors and run the next mock
Wrote unit1b/calder_mock_1.md
The exit code is 1. Look at three things in this result:
Errors cascade. Seven partner errors cause ten more downstream: purchase orders and open items can't load for partners that didn't load. Fix the master data and most of the rest disappears.
Some damage doesn't show as an error. Invoice 95001109 loaded, because customer 10000003 loaded. But 10000003 is the duplicate of 10000001, so the target now holds a duplicate customer with a real receivable on it. Only the profile and the suggestions caught that. In a real system you can't delete it; you start the next mock in a fresh client.
Counts and amounts disagree in size. Stock is short by 52,244.00 EUR from only three records, because the two impellers are the most valuable stock. Reconcile amounts, not just counts.
The list DECISIONS in the script holds 19 decisions, each with the person who approved it and whether it is fixed in the source (ECC) or in the mapping. That list is the decision log a real program keeps.
Run:
Windows (PowerShell):python unit1b\migration.py --mock 2
macOS / Linux:python unit1b/migration.py --mock 2
You should see:
Mock load 2, CP20 extract 2027-01-31 (fictional data)
Applied 19 approved decisions
customer 10000001: renumber (map), approved by Master data lead
customer 10000003: merge (map), approved by AR lead CP20
customer 10000004: fix (source), approved by AR lead CP20
customer 10000005: fix (source), approved by AR lead CP20
customer 10000007: exclude (map), approved by AR lead CP20
customer 10000010: fix (source), approved by Group accounting
supplier 20000004: merge (map), approved by Purchasing lead CP20
supplier 20000007: extend (map), approved by Master data lead
supplier 20000008: distinct (map), approved by Purchasing lead CP20
supplier 20000009: fix (source), approved by AP lead CP20
supplier 20000012: exclude (map), approved by Purchasing lead CP20
product CP-IMP200: extend (map), approved by Materials planner CP21
product CP-IMP300: extend (map), approved by Materials planner CP21
product CP-BAR42-OLD: exclude (map), approved by Materials planner CP21
product CP-FIX-07: exclude (map), approved by Materials planner CP21
product CP-CTOOL: fix (source), approved by Materials planner CP21
stock CP-FIX-07: exclude (map), approved by Plant controller CP21
purchase_order 4500001205: fix (source), approved by Purchasing lead CP20
purchase_order 4500001207: close (source), approved by Purchasing lead CP20
Counts: in scope, loaded or mapped, excluded with reason
suppliers 7 6 1 OK
customers 7 6 1 OK
products 9 7 2 OK
stock 8 7 1 OK
purchase_orders 5 5 0 OK
ar_items 5 5 0 OK
ap_items 4 4 0 OK
Amounts: ECC control total vs loaded
AR open items (EUR) 29,900.00 29,900.00 OK
AP open items (EUR) 10,025.00 10,025.00 OK
Stock value CP21 (EUR) 78,956.00 78,956.00 OK
Open PO value (EUR) 10,601.00 10,601.00 OK
Exit criteria
PASS No load errors
PASS Every count reconciles
PASS Every amount reconciles to 0.00
PASS Every profile finding has an approved decision
Result: ready for the dress rehearsal
Wrote unit1b/calder_mock_2.md
The exit code is 0. Notice the open PO control total fell from 11,421.00 to 10,601.00 EUR. That isn't a loss: purchasing closed the stale PO 4500001207 in ECC, so the source total changed with it. A control total always comes from the source as it stands after source-side cleansing.
To adjust source selection and field mapping; custom fields mapped by hand (SAP Community FAQ)
Which objects exist
Object list on the SAP Help Portal per release
Linked from the community FAQ; names change between releases
Duplicates and best records
SAP Master Data Governance, consolidation
Separate product; matching for business partner and product (SAP Learning)
What the cockpit doesn't do matters as much for the plan. According to SAP's product team, it migrates each record once, offers no delta loads, isn't built to keep two systems in sync, and has no deletion function (SAP Community). The community FAQ adds that updating data already in the target is out of scope (SAP Community FAQ). For Calder that settles three design points: one final load at cutover rather than a trickle, fresh clients for each mock, and extend objects for records that already exist.
For Calder's source, ECC 6.0 with enhancement package 8, and target, S/4HANA Cloud Private Edition 2023, direct transfer by company code CP20 is the natural route for the objects it supports. The plan still needs the staging route for objects outside that set and for the records the team transforms outside the source. Which objects fall where is a check against the object list, not something to assume.
Where it sits. The migration runs between Calder's two production systems, with the mocks in the quality landscape.
flowchart LR
ECP[(ECC ECP<br/>CP20)] -->|RFC, direct transfer| MC[Migration cockpit<br/>in S4Q / S4P]
ECP -->|extract| ST[(Staging tables)]
ST --> MC
PR[Profiling and<br/>reconciliation scripts] -.read.-> ECP
PR -.read.-> S4P
MC --> S4P[(S/4HANA S4P<br/>CP10 + CP20)]
PR --> RC[Signed reconciliation]
Mocks load into S4Q clients refreshed for each cycle; the production load goes into S4P. The scripts read both sides and write only reports.
Non-functional requirements that decide the design
Requirement
Typical target
If missed
Cutover window
Full load and reconciliation in under half the window, leaving the rest for checks and fallback
No time to reconcile; go/no-go made blind
Data volume
Every object loaded at full production volume in at least one mock
Load times in production are a surprise
Accuracy
0.00 difference on financial objects; every exclusion approved
Opening balances can't be signed
Repeatability
Every fix is a source change, rule or logged decision
Mocks pass, production fails
Data protection
Extracts and staging tables treated like production data
Customer and bank data leak from test systems
Auditability
Control totals, key map and signed reconciliations kept
Auditors can't trace opening balances
Integration and identity. Direct transfer reads ECC over an RFC connection with a technical user; restrict that user to read access in the source and to the migration roles in the target, and remove it after go-live. Business partner roles, company code and plant values come from the configuration transported before the load. After cutover, the EDI link between CP10 and CP20 is retired, and users in Augsburg need roles restricted to CP20 and CP21 (Roles and authorization design). Anyone who can run migration objects in production can create suppliers and post open items, so that access is emergency-style: granted for the cutover, logged and removed.
Context:CP20 moves from ECC into CP10's S/4HANA system (option A). Some of its suppliers and products already exist there, some numbers collide, and the migration cockpit can't update or delete records.
Options: (a) direct transfer for every supported object, cleansing in ECC beforehand; (b) extract everything to staging tables and transform outside the source; (c) a mix: cleanse in ECC, direct transfer for supported objects, staging tables for extend objects and anything transformed.
Decision: (c). Cleanse in the source wherever possible, load by company code CP20, and keep merges, renumbering and extensions in one key map used by every object.
Consequences: Two load routes to test in every mock. The key map becomes a controlled artifact with an owner. Source data must be frozen before cutover so the last cleansing holds.
What a design review will ask
Which objects load by direct transfer and which by staging tables, and was that checked against the object list for the target release?
Who owns the key map, and how do purchase orders and open items follow merged and renumbered partners?
What are the exit criteria for each mock, and who decides when they are met?
How long does the load take at full volume, and how much of the window is left for reconciliation and fallback?
Where do users read CP20's closed history after go-live, and for how long?
Scores likely duplicates, including fuzzy names across languages
Owner approves each merge or "distinct"; decisions logged and replayed
Field and value mapping
Retrieve and extract
Suggests target fields for legacy headers and values (Basis-ME to base unit)
Data owner confirms; mapping stored as a rule, not re-asked each mock
Explaining load errors
Retrieve
Groups error messages and points to the likely root cause, such as a partner that didn't load
Migration lead assigns the fix
Loading data
Plain automation (the cockpit)
Runs the load
No AI writes to the target
Reconciliation sign-off
None
Counts and amounts are arithmetic
The business owner signs
Two rules apply throughout. Suggestions, never actions: an AI tool reads the extract and writes suggestions to a review list; it never merges, excludes or loads. And evaluate before trusting: build a labeled set of known duplicate and distinct pairs from steward decisions, and measure how many flags were right and how many real duplicates were missed, as the master data topic describes. The data readiness topic goes further on profiling SAP data for AI, and AI in S/4HANA migration projects covers AI that supports migration projects as a whole.
Security and authorizations. Extracts contain customer, supplier and bank data. Store them encrypted, delete them after go-live, and never copy them to personal drives. Give migration roles in production only for the cutover window. Bank details deserve a second check after loading, since a changed digit in a mock can reach production through a mapping rule.
Evaluation. Treat each mock as a test run with a report: errors by object and root cause, load times, reconciliation results. Track the error count across mocks; it should fall sharply between mock 1 and mock 2.
Cost. Mocks need systems or clients refreshed for each cycle, people from the business for each reconciliation, and weekends for the dress rehearsal. Budget them at the start; cutting a mock to save time usually costs the go-live date.
Operations. Freeze master data changes in the source before cutover, or every late change must be repeated in the target. Keep a hypercare list for the first weeks: migrated records that fail in processes go to the owner who approved them.
Clean core. Load through SAP's migration objects and released interfaces, never through direct table writes. Custom fields added with the object modeler should map to fields created with the system's own extensibility tools.
Starting late. Cleansing takes months of business time. Profiling should start when the program does, not when configuration is finished.
Fixing in the target. A fix made by hand in a mock client is lost with the next refresh. Make it a source change, a rule or a logged decision.
Ignoring records that already exist. In a rollout into a live system, collisions and duplicates against the target are the first-mock surprise.
Matching on names alone. Similar names find candidates; tax numbers and bank accounts decide. Different legal entities with similar names must stay apart.
Reconciling counts only. Three missing records can carry most of the value, as Calder's stock showed.
No fallback. A go/no-go point without a tested way back to the old system is a formality.
Migrating stale documents. A purchase order due two years ago will confuse MRP in the new system. Close it in the source.
Run python unit1b/migration.py --mock 2 again. Confirm it fails with 2 load errors, a count difference and an AR difference of 1,980.00, and that the exit code is 1.
Add a decision to DECISIONS that fills the payment terms in the source, approved by the AR lead:
("customers", "10000012", "fix", {"terms": "NT30"}, "source", "AR lead CP20"),
Run --mock 2 once more. Confirm all four criteria pass and AR open items reconcile at 31,880.00 EUR.
Create unit1b/calder_migration_design.md. Under Object list and load sequence, copy the table and diagram from unit1b/calder_migration_plan.md, and add a column Route (direct transfer or staging tables) with a note that it must be checked against the object list for the target release.
Under Reconciliation checks, write one line per object: the control total, who produces it, who signs.
Under Decision log, list the decisions by kind (fix, merge, extend, renumber, exclude, close) with their counts and approvers.
Under Cutover, copy Calder's cutover timeline from the deep layer and mark the go/no-go and fallback deadline.
Add one line on AI controls (what a duplicate or mapping suggestion may and may not do) and one line on the ECC difference (customers and vendors becoming business partners).
Preview the file to check the table and diagram render, then commit it with Git.
Done when:unit1b/calder_migration_design.md has the object list with a Route column, a reconciliation line for each of the 9 objects, a decision log with 20 decisions, the cutover timeline with go/no-go and fallback marked, an AI controls line and an ECC line, and --mock 2 passes with 31,880.00 EUR receivables. Keep it: the Unit 1B capstone, One exception, traced across the enterprise, reuses it in its solution design summary.
Pick one answer for each question. The explanation appears after you choose.
1Why does the factory model insist that every fix become a rule or a logged decision?
Answer: C. Each mock runs on a fresh extract into a fresh client, so only fixes in the source, in rules or in the decision log repeat. A hand fix in the target vanishes, and production would fail where the mock passed.
2In mock 1, ten of the 21 errors are on purchase orders and open items. What is the most likely root cause?
Answer: B. Each failed purchase order and open item names a supplier or customer that didn't load. Fixing the seven partner errors removes most downstream errors, which is why the load sequence follows the references.
3Supplier 20000007 in CP20 has the same tax number as Ridgeline, supplier 20000003 in CP10's system. What should the migration do?
Answer: C. The cockpit creates records and doesn't update existing ones, and production records can't be deleted. Extending the existing business partner with CP20's company code and purchasing organization keeps one supplier, and the key map sends 20000007's POs and invoices to it.
4Mock 1 loaded invoice 95001109 for customer 10000003 without an error. Why is that still a problem?
Answer: A. 10000003 loaded only because its twin failed on a number collision. No load check flagged it; the duplicate suggestion did. In a real system the record can't be deleted, so the next mock starts in a fresh client.
5The open PO control total dropped from 11,421.00 to 10,601.00 EUR between mocks. What explains it?
Answer: D. Control totals come from the source as it stands after source-side cleansing. Closing the PO due in 2024 removed 820.00 EUR of open value in ECC, and the target matches the new total.
6Why does Calder load G/L balances after payables, receivables and stock?
Answer: D. It's a course design choice: once payables, receivables and stock have posted, the G/L load completes the picture, and the trial balance and the netting of offsets can be checked on everything.
7What would you do if an AI tool rated Ridgeline Bearing Supply Europe GmbH a 0.87 match with Ridgeline Bearing Supply?
Answer: D. A similar name is weak evidence; the tax numbers differ, so they may be separate legal entities. The tool suggests and a named owner decides, and the decision is logged so the next mock repeats it.
8Which control matters most for the user that runs migration objects in production?
Answer: B. Whoever runs migration objects in production can create suppliers and post open items. That is emergency-level access: time-boxed, logged and removed, as with firefighter access in the roles topic.
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.
Introducing the SAP S/4HANA Migration Cockpit (SAP Learning, implementing SAP S/4HANA Cloud Public Edition)— Migrate Your Data app; on-premise, private and public cloud, with templates varying by deployment; embedded at no additional cost; staging tables with a local schema (XML or CSV templates) or a remote SAP HANA schema filled by ETL code or SAP Data Services; direct transfer over RFC from a compatible SAP ERP system, converted to the S/4HANA data model; validation app named per object
Migrating Data to SAP S/4HANA Cloud Public Edition with Local Templates (SAP Learning)— project with migration objects and a staging table each; predecessor objects must be migrated first; steps create project, select objects, download XML or CSV template, upload, prepare staging tables, mapping tasks (value mapping, fixed values), simulate, migrate, correction file, close project; 100 MB file limit
SAP S/4HANA Migration Cockpit FAQ (SAP Community topic page)— designed for the initial load; direct transfer for Public Edition released with 2402; LTMOM to adjust selection and field mapping; custom fields mapped manually; object list at help.sap.com/S4_CE_MO; updating existing data out of scope