Orchestrate

Report an issue

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

0 / 2000

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

Hire-to-retire: where people data meets logistics and finance

See where employee data crosses into S/4HANA (cost centers, time, expenses, payroll), what breaks at each crossing, and why HR data stays out of AI grounding.

Updated Nov 4, 2026Foundational 8 minDeep 35 min
Foundational layer · 8 min read

The 60-second version

Hire to retire is everything a company does with its people: hiring, moving, paying, recording time, reimbursing travel, and handling someone's exit. Most of it runs in an HR system. This topic is not about HR. It is about the places where people data crosses into the ERP, because that is where logistics and finance depend on it.

There are five crossings to know:

  • Cost centers go out to HR. Finance owns cost centers. HR assigns each person to one.
  • People come back from HR. S/4HANA needs a record of each employee, so a technician can confirm hours on an order and a buyer can be reimbursed.
  • Time goes to orders. A technician's hours become cost on a maintenance order.
  • Expenses go to finance. A travel claim becomes a posting and a payment to the employee.
  • Payroll goes to the ledger. Each pay run sends a posting of wages and liabilities, usually summed by cost center.

Three ideas carry this topic:

  • One stale record breaks four processes. If HR assigns people to a cost center finance has closed, payroll, time, expenses and the employee record all fail at once.
  • Each crossing should carry the minimum. Finance needs wages by cost center, not each person's salary. A scheduler needs "available or not", not why someone is away.
  • People data is the most sensitive data in the landscape. That is why it is usually kept out of AI grounding, and why a leaver's access must end on their last day.

Why it matters to the business

When a crossing fails, the symptom shows up somewhere else: an order that looks cheap, a close that is missing a month of wages, a new hire who can't be paid back for safety boots.

Here is Monday 2 November 2026, the first working day of the October close at Calder Pumpworks, the fictional pump maker this unit follows. Calder's US company (CP10) runs S/4HANA; in this course its cloud HR system is SAP SuccessFactors Employee Central. A check of the people-data crossings finds this:

What the check shows What it means Who acts
Cost center CP11-435 (rework area) closed on 30 September in S/4HANA, still open in HR The cost center feed to HR stopped on 14 September HR integration team
A maintenance technician hired on 19 October isn't in S/4HANA HR gave him the old rework position, on the closed cost center; S/4HANA rejected the record HR, with the cost accountant
October payroll posting is held: 7,350.00 USD on the closed cost center In this scenario the whole document waits, so October wages are missing from CP10's books Payroll, with the G/L accountant
14 hours of the new technician's work can't reach order 4000126 The crane brake rework looks 1,330.00 USD cheaper than it was Maintenance supervisor
His expense report for safety boots can't post No employee record to pay, and a closed cost center Travel and expense admin
A technician who left on 16 October still has an active S/4HANA user Access outlived the job Security administrator

The business decision is who owns each crossing, and what it is allowed to carry. Five of the six findings above come from one feed nobody watched.

How SAP does it

SAP's HR products and S/4HANA connect through standard integrations. As of October 2026, SAP's documentation describes these:

  • SAP SuccessFactors Employee Central with S/4HANA Cloud Public Edition. SAP's training lists scope items for workforce replication through SAP Master Data Integration, a service on SAP BTP, for financial master data such as cost centers going to SuccessFactors, and for payroll with Employee Central Payroll. HR data is then edited in Employee Central, not in S/4HANA's Manage Workforce app (SAP Learning). That page is from the public sector training; check the scope items for your edition.
  • Employee Central with SAP HCM in S/4HANA (on-premise or private cloud). Employee and organizational data replicate from Employee Central into the HR tables of S/4HANA, including the employee's cost center (SAP Learning).
  • On-premise HR in S/4HANA. SAP's compatibility packs for HCM ended with 2025. SAP says that from 2026, running HCM on-premise needs SAP HCM for SAP S/4HANA licensed and activated, while employee data used only by non-HR processes doesn't (SAP, 2024). Check your contract with SAP.
  • Travel and expenses with SAP Concur. SAP describes SAP Integration with Concur Solutions (SAP ICS) as its native framework linking SAP finance systems with Concur, for both S/4HANA Cloud and on-premise (SAP Learning).
  • Payroll from any provider. S/4HANA Cloud can send cost centers and company codes to a third-party payroll system and take its results as journal entries (SAP guide, 2019).

In every option, the HR system owns people and S/4HANA owns cost centers. Each side sends the other only what it needs.

The crossings on one page

flowchart LR
  S4[S/4HANA<br/>finance, logistics] -->|cost centers| HR[HR system<br/>people]
  HR -->|employees,<br/>assignments| S4
  HR --> PY[Payroll]
  PY -->|posting<br/>document| S4
  T[Time<br/>recording] -->|confirmations| S4
  EX[Travel and<br/>expense] -->|postings,<br/>reimbursement| S4
  HR --> T
  HR --> EX
Crossing Team that owns it What S/4HANA receives or sends Posts to finance?
Cost centers to HR Controlling, HR integration Cost centers with validity dates No
Hire, transfer, leave HR Employee and employment records, cost center, work center No
Time to orders Supervisors, technicians Confirmations with a personnel number Yes: order debited, work center's cost center credited
Expenses Travel and expense team Expense postings; the employee as payee Yes: expense against amount owed to the employee
Payroll Payroll team A posting document per pay run Yes: wages and taxes against payables
Leaver HR, security End of employment; user locked No

Questions to ask

  1. Which system owns cost centers, which owns people, and who is told when either changes?
  2. How do we notice when a replication job stops? Who watches it, and how soon?
  3. What does payroll post to finance: totals by cost center, or one line per person? Who can see the detail?
  4. Can a new hire confirm time and claim expenses in their first week? Who checks?
  5. When someone leaves, what happens the same day to their S/4HANA user, open time and open expenses?
  6. Which HR fields does any AI tool read, and who approved each one?
  7. For each company, where does HR live: a cloud HR system, HR inside S/4HANA, or HR inside ECC?

Common misconceptions

  • "HR is a separate world." Payroll is often one of the largest postings of the month, and maintenance cost depends on hours booked by people.
  • "Finance needs each person's pay." It needs wages and liabilities by cost center and account. Detail by person stays in payroll unless a process requires it.
  • "Integration is set up once." Feeds stop, mappings drift and cost centers close. A replication monitor needs an owner, like any queue.
  • "A leaver is an HR event." It is also an access event. Their user, open time and open expense reports each need an action.
  • "HR data makes AI assistants smarter." Most use cases need a fact derived from HR, such as "available on Tuesday", not the HR record behind it.

Key terms

All course terms, A to Z, in the glossary

  • Hire to retire: the end-to-end process for employees, from recruiting to exit.
  • System of record: the one system where a piece of data is created and changed; others receive copies.
  • Replication: copying records from the system of record to another system, on a schedule or on change.
  • Personnel number: the ID of an employee's employment in SAP HR and on time records.
  • Employee business partner: S/4HANA's record of a person as an employee, used by logistics and finance processes.
  • Payroll posting document: the summary a pay run sends to finance: wages, taxes and payables by account and cost assignment.
  • Confirmation: a record of work done on an order, with hours that become cost.
  • Data minimization: sending or storing only the personal data a purpose needs.

Check yourself

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

1At Calder, five findings trace to one cause. What is it?

Answer: A. HR kept assigning people to a cost center finance had closed, because the feed stopped in September. Payroll, the employee record, time and expenses then all failed on the same cost center.

2What should payroll usually send to finance for each pay run?

Answer: C. Finance needs cost by cost center and the liabilities to pay. Individual pay stays in payroll, which keeps the most sensitive data in the fewest places.

3Order 4000126 looks 1,330.00 USD cheaper than the work it took. Why?

Answer: A. A confirmation needs a valid personnel number. Until the employee record replicates, his hours wait outside the order, so the order's cost is understated.

4A technician left on 16 October. Which follow-up is most urgent on the S/4HANA side?

Answer: B. An active user after the last day is a security risk. His record must stay for retention and his legitimate expenses must still be paid; deleting or blocking them would be wrong.

5A vendor's scheduling assistant asks to read the HR record of every technician. What do you offer instead?

Answer: C. Scheduling needs who can work when and on what, not why someone is away. Deriving that small fact keeps health, pay and family data out of the tool.

6Calder plans to keep HR on-premise in its S/4HANA system after 2026. What must it check with SAP?

Answer: B. SAP says the HCM compatibility packs ended with 2025, and on-premise HCM then needs SAP HCM for SAP S/4HANA licensed and activated. The other options misplace where data is owned.
Deep layer · 35 min read

Mental model

Think of hire to retire as two systems of record facing each other, with narrow pipes between them. Finance owns the cost structure; HR owns the people. Each pipe carries a small, defined slice:

  • Out of S/4HANA: cost centers with validity dates.
  • Into S/4HANA: a person's employment, with cost center and work center.
  • Into S/4HANA from people's work: hours on orders, expense claims.
  • Into the ledger: one payroll posting document per pay run.

Two rules follow. Every pipe needs an owner and a monitor, because a stopped pipe looks like a business problem far away. And every pipe should carry the minimum: the narrower the slice, the less personal data spreads, and the fewer places a breach or a wrong AI answer can come from. This topic's script checks both at Calder.

Business objects and documents

Before you start: this topic builds on How an SAP enterprise is wired (company codes, controlling area, cost centers), Master data every module depends on (business partners), Maintain to settle (work centers, confirmations) and Record to report (the close). It doesn't repeat them.

The person in S/4HANA

S/4HANA doesn't keep the HR file. It keeps what its own processes need about a person, as business partners:

  • On-premise, from S/4HANA 2020, each employee becomes a business partner with the employee role and an employment business partner (roles BUP003 and BUP010). A supplier role, needed to pay the person, is optional. Background jobs keep them in step with the HR tables. In SAP ERP, travel used separate employee vendors, created with transaction PRAA, which is no longer valid in S/4HANA (SAP Community, blogs by SAP).
  • In S/4HANA Cloud Public Edition, the person is a workforce person with work agreements. They arrive through one channel at a time: the Manage Workforce app, a SOAP API, the Employee Central integration or SAP Master Data Integration. The roles include BUP003 Employee, BUP010 Employment, BBP005 Contingent Worker and BUP011 External Employment. Duplicates appear when the person ID doesn't match across channels (SAP KBA 2813743).
  • When Employee Central sends payment information with a pay type for expenses, S/4HANA Cloud creates the business partner with the Employee and Supplier (FLVN00) roles, so expenses can be paid. The bank must already exist in S/4HANA, or replication fails (SAP Community, 2019).

With SAP HCM in S/4HANA, the HR record itself lives in the system as infotypes: numbered record types such as 0001 Organizational Assignment, which stores the employee's cost center (SAP Learning).

Cost centers, the other direction

Cost centers are created in S/4HANA and copied to the HR and payroll systems, where people are assigned to them. SAP's public sector training describes cost centers going from S/4HANA Cloud to SuccessFactors through SAP Master Data Integration (SAP Learning). For a third-party payroll system, SAP's guide uses the Data Replication Framework under communication scenario SAP_COM_0179 (SAP guide, 2019). The validity dates matter: a cost center closed in S/4HANA must be closed in HR too.

Time records

Hours reach orders in two ways. A technician can confirm directly on the order: the maintenance confirmation carries PersonnelNumber, ActualWorkQuantity and the order (SAP Cloud SDK model, 2022). Or the person records time in a timesheet, which is transferred later. SAP's training on the Cross-Application Time Sheet (CATS) describes time recorded once and transferred, after release and any approval, to HR, project system, plant maintenance, customer service, controlling or purchasing. A confirmation creates an activity allocation: the order is debited and the work center's cost center credited. CATS can't confirm production orders; hours on one become an activity allocation without a confirmation (SAP Learning).

At Calder (fictional), technicians on work centers MECH1 (95.00 USD/h) and ELEC1 (105.00 USD/h) confirm hours on maintenance orders. The new technician's 14 hours on order 4000126 wait, because his personnel number isn't in S/4HANA.

Expense reports

An approved expense report becomes a posting: expense accounts with cost center or project, against an amount owed to the employee, who is paid like a supplier. A 2018 SAP Community post on the S/4HANA Cloud integration with SAP Concur (scope item 1M1) describes it sending cost centers and WBS elements to Concur and posting expense reports back as journal entries (SAP Community, 2018). The old BAPI for posting employee expenses, BAPI_ACC_EMPLOYEE_EXP_POST, is marked not to be released for the cloud, with the journal entry interface as its successor (Cloudification Repository).

The payroll posting document

A pay run ends with a posting run. SAP's training for HCM payroll in S/4HANA walks through it: an optional simulation, a live posting run for a payroll area and period, release of the posting documents, then posting with transaction PCP0 (SAP Learning). With Employee Central Payroll and S/4HANA Cloud, the posting document is transferred to the general ledger and posted as an expense and liability (SAP Learning). A third-party provider posts journal entries through the SOAP journal entry service under SAP_COM_0002 (SAP guide, 2019).

Document flow and postings

flowchart LR
  CC[Cost center<br/>CP11-435 closed] -.->|feed stopped| HR[HR: 2 people<br/>on CP11-435]
  HR -.->|rejected| BP[No employee<br/>00100231]
  BP -.-> T[14.0 h waiting<br/>order 4000126]
  BP -.-> E[ER-2026-0419<br/>can't post]
  HR --> PY[October payroll<br/>236,010.00]
  PY -.->|held| GL[CP10 ledger]
Step Posts to finance Why
Cost center or employee replication Nothing Master data only, but every later posting depends on it
Time confirmation on a maintenance order Order debited, work center's cost center credited (activity allocation) Labor becomes order cost
Expense report Expense (cost center or project) debited, amount owed to the employee credited The company owes the employee
Reimbursement Amount owed to the employee cleared against the bank The employee is paid like a supplier
Payroll posting Wages and employer taxes debited by cost center; net pay and withholdings payable credited The month's labor cost and what the company owes
Payroll payment Net pay payable cleared against the bank Employees are paid

At Calder (fictional), the October payroll posting has 236,010.00 USD on each side: 219,230.00 wages and 16,780.00 employer taxes against 158,640.00 net pay, 60,590.00 withholdings and 16,780.00 employer taxes payable. 7,350.00 of the debits sit on CP11-435. You compute this in Step 5.

Neighbors

Neighbor Gives hire to retire Takes from hire to retire When the link fails
Controlling (record to report) Cost centers, validity dates Payroll cost by cost center Payroll posting held; labor cost missing at close
Maintain to settle Orders to confirm Technicians, personnel numbers, work centers Hours can't post; orders look cheap
Plan to produce Production orders Hours by work center Activity costs wrong on orders
Procure to pay Payment run, supplier records Employees as payees for expenses Reimbursements blocked
Security and roles Business users, role assignments Joiners, movers, leavers Leavers keep access; new hires can't work
Travel and expense system Expense reports Employees, cost centers, approvers Reports can't post or post to the wrong cost center
Data warehouse and analytics Headcount and cost reports Pseudonymized or aggregated people data Personal data spreads into reports nobody controls

Where it breaks

Symptom Usual cause Who resolves it What it costs Calder (scenario)
New hire missing in S/4HANA HR assignment on a cost center closed in S/4HANA HR integration team 14.0 h (1,330.00 USD) missing from order 4000126
Payroll posting held A line on a closed cost center Payroll, G/L accountant 7,350.00 USD; October wages missing from CP10 until fixed
Expense report can't post No employee business partner; closed cost center Travel and expense admin 486.75 USD; a new hire waits for his money
Leaver's user still active Leaver event doesn't reach user administration Security administrator Audit finding; access risk
Hours booked after a leaving date Wrong personnel number keyed Maintenance supervisor 4.0 h (380.00 USD) on the wrong person
Duplicate employee records IDs don't match across replication channels HR integration team Two records per person; wrong reports

How it is exposed

As of 24 October 2026, these released interfaces carry each crossing in S/4HANA Cloud. The CDS views come from SAP's Cloudification Repository (file read 24 October 2026). The timesheet API and its fields come from SAP's generated API model, published in 2022 (SAP Cloud SDK model).

Crossing API or service Released CDS views What to read
Workforce SOAP Replicate Basic Master Data for Workforce from External HR Systems (named in SAP KBA 2813743) I_WorkforcePerson_1, I_PersonWorkAgreement_1, I_PersonWorkAgrmtOrglDetails, I_PersonWorkAgrmtPlndWrkgTime, I_BPEmployment Person, work agreement, organizational assignment, planned working time
Users I_BusinessUserBasic (successor of the deprecated I_BusinessUser) Who can log on, linked to which person
Cost centers DRF replication under SAP_COM_0179 I_CostCenter Validity dates, company code
Time API_MANAGE_WORKFORCE_TIMESHEET (TimeSheetEntryCollection); API_MAINTORDERCONFIRMATION I_TimeSheetRecord, I_TimeSheet PersonWorkAgreement, TimeSheetDate, TimeSheetStatus, and in TimeSheetDataFields: ActivityType, RecordedHours, ReceiverCostCenter, WBSElement, InternalOrder
Expenses and payroll Journal Entry - Post (SOAP) under SAP_COM_0002 I_JournalEntryItem (see record to report) Postings by account and cost center

Note what the timesheet model offers as receivers: cost center, WBS element, internal order, purchase order. Maintenance orders get hours through their own confirmation API, which carries PersonnelNumber.

Build it yourself

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

You will write one script that checks Calder's people-data crossings on 2 November 2026. It compares cost centers between S/4HANA and HR, replicates employees and finds who is rejected, checks the October payroll posting, the technicians' time and the waiting expense reports, and groups every finding by root cause. Then it shows what a scheduling assistant may see about technicians, after minimization. An optional step reads timesheet records from SAP's sandbox.

flowchart LR
  P[calder_pack/<br/>company codes,<br/>controlling area] --> S[hire_to_retire.py]
  SC[Scenario in the script<br/>cost centers, people,<br/>payroll, time, expenses] --> S
  S --> C[Crossings check]
  S --> PY[--payroll]
  S --> T[--time]
  S --> E[--expenses]
  S --> AI[--ai minimized view]
  S --> R[calder_people_crossings.md]
  K[SAP sandbox<br/>optional] --> S

What you need

  • The course folder orchestrate-course with its .venv, and the Unit 1B sample pack.
  • For Steps 1 to 8: no account, no key and no network. The script uses only Python's built-in modules, so requirements.txt doesn't change.
  • For Step 9 (optional): your free SAP Business Accelerator Hub key as SAP_API_KEY in .env, from the course setup.
  • Cost: free. Time: about 35 minutes.

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

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

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

      source .venv/bin/activate

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

Step 2: Make sure the sample pack is current

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

    • Windows (PowerShell):

      python unit1b\make_calder_pack.py
    • macOS / Linux:

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

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

The script checks that company codes CP10 (S/4HANA) and CP20 (ECC) and controlling area CP00 are in the pack.

Step 3: Create the script

  1. In VS Code, create a new file, paste the script below, and save it as hire_to_retire.py inside the unit1b folder.
  2. Read the blocks S4_COST_CENTERS, HR_EMPLOYEES, BUSINESS_USERS, PAYROLL_BY_CC, TIME_RECORDS, EXPENSE_REPORTS and AI_FIELD_POLICY first. They are Calder's people data written as Python data, and every value is invented. Order 4000126 and the work centers come from the maintain-to-settle topic. Personnel numbers, accounts, report IDs and skill tags are the course's own, not SAP values.
  3. Notice what isn't there: no names, no addresses, no individual pay. The payroll data arrives already summed by cost center, the way it should cross to finance.
"""Hire to retire at Calder: where people data crosses into logistics and finance.

Calder Pumpworks is a fictional company. The pack (unit1b/calder_pack/) and the
scenario below (employees, cost centers, payroll totals, time records, expense
reports, absences) are made up for the course. Field names follow SAP's released
APIs where the script mirrors one (API_MANAGE_WORKFORCE_TIMESHEET, the workforce
and business partner models), but the logic is a teaching sketch, not SAP's.
Personnel numbers, G/L accounts, cost centers, report IDs and skill tags are
course labels, not SAP values. On purpose, the script holds no names, no
addresses and no individual pay: only what each crossing needs.

Run it from your course folder:
  python unit1b/hire_to_retire.py               the crossings check for CP10 on 2026-11-02
  python unit1b/hire_to_retire.py --payroll     October payroll posting document by cost center
  python unit1b/hire_to_retire.py --time        technician time waiting to reach maintenance orders
  python unit1b/hire_to_retire.py --expenses    expense reports waiting to post
  python unit1b/hire_to_retire.py --ai          what a scheduling assistant may see, minimized
  python unit1b/hire_to_retire.py --write       write unit1b/calder_people_crossings.md
  python unit1b/hire_to_retire.py --sandbox     read timesheet records from SAP's sandbox (needs SAP_API_KEY)
Steps without --sandbox use only Python's built-in modules and need no account and no network.
"""
import argparse
import hashlib
import hmac
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path

HERE = Path(__file__).resolve().parent
PACK = HERE / "calder_pack"
AS_OF = "2026-11-02"            # Monday, workday 1 of the October close
PERIOD = "2026-10"

# ------------------------------------------------------------ the fixes (the exercise changes these)
# Has cost center replication from S/4HANA to the HR system run again since CP11-435 closed?
CC_REPLICATION_RUN = False
# New cost centers HR enters for people still on a closed cost center: {personnel number: cost center}
HR_FIXES = {}

# ------------------------------------------------------------ cost centers (fictional, controlling area CP00)
# S/4HANA CP10 is the system of record for cost centers. (cost center, name, valid from, valid to)
S4_COST_CENTERS = [
    ("CP11-300", "Rockford purchasing", "2020-01-01", "9999-12-31"),
    ("CP11-410", "Assembly", "2020-01-01", "9999-12-31"),
    ("CP11-420", "Test", "2020-01-01", "9999-12-31"),
    ("CP11-435", "Rework area", "2023-01-01", "2026-09-30"),   # closed: rework moved into assembly
    ("CP11-450", "Plant maintenance", "2020-01-01", "9999-12-31"),
    ("CP11-460", "Utilities", "2020-01-01", "9999-12-31"),
    ("CP11-900", "Rockford administration", "2020-01-01", "9999-12-31"),
]
# What the HR system last received. The replication job last succeeded on 2026-09-14.
HR_CC_LAST_REPLICATED = "2026-09-14"


def hr_cost_centers() -> dict:
    if CC_REPLICATION_RUN:
        return {cc: (vf, vt) for cc, _, vf, vt in S4_COST_CENTERS}
    return {cc: (vf, "9999-12-31" if cc == "CP11-435" else vt) for cc, _, vf, vt in S4_COST_CENTERS}


def s4_valid(cc: str, on: str) -> bool:
    return any(c == cc and vf <= on <= vt for c, _, vf, vt in S4_COST_CENTERS)


# ------------------------------------------------------------ people (fictional, CP10 Rockford operations, a subset)
# The HR system (Employee Central in this course) is the system of record for people.
# (personnel number, job, work center, cost center, employment start, employment end or "")
HR_EMPLOYEES = [
    ("00100094", "Maintenance technician", "MECH1", "CP11-450", "2019-03-04", "2026-10-16"),
    ("00100117", "Maintenance technician", "MECH1", "CP11-450", "2018-06-11", ""),
    ("00100142", "Maintenance electrician", "ELEC1", "CP11-450", "2021-02-01", ""),
    ("00100153", "Maintenance planner", "", "CP11-450", "2017-09-18", ""),
    ("00100188", "Assembler", "ASSY1", "CP11-410", "2022-04-25", ""),
    ("00100196", "Test technician", "TEST1", "CP11-420", "2020-08-03", ""),
    ("00100205", "Rework technician", "ASSY1", "CP11-435", "2023-01-09", ""),
    ("00100211", "Buyer", "", "CP11-300", "2024-05-13", ""),
    ("00100231", "Maintenance technician", "MECH1", "CP11-435", "2026-10-19", ""),  # new hire, reused rework position
]
# S/4HANA business users linked to these people: (personnel number, user valid to)
BUSINESS_USERS = [("00100094", "9999-12-31"), ("00100117", "9999-12-31"), ("00100142", "9999-12-31"),
                  ("00100153", "9999-12-31"), ("00100211", "9999-12-31")]


def hr_people() -> list:
    return [(p, job, wc, HR_FIXES.get(p, cc), start, end) for p, job, wc, cc, start, end in HR_EMPLOYEES]


def replicate() -> tuple:
    """Employee replication HR -> S/4HANA: one employee and one employment business partner per person.
    In this scenario S/4HANA rejects a record whose cost center isn't valid on the employment start."""
    s4, rejected = {}, {}
    for p, job, wc, cc, start, end in hr_people():
        existed_before = start < "2026-10-01"
        check_date = start if not existed_before else "2026-10-01"
        if not existed_before and not s4_valid(cc, check_date):
            rejected[p] = f"cost center {cc} not valid on {check_date}"
            continue
        s4[p] = {"WorkCenter": wc, "CostCenter": cc, "Start": start, "End": end,
                 "BPRoles": ["BUP003", "BUP010"]}       # employee, employment
    return s4, rejected


# ------------------------------------------------------------ payroll (fictional, summarized by cost center)
# October payroll posting document from the payroll system, posting date 2026-10-30, USD.
# Lines: (G/L account, name, cost center, debit, credit). Accounts are course labels.
GL_WAGES, GL_ER_TAX = "61100000", "61200000"
PAYROLL_BY_CC = {   # cost center: (wages and salaries, employer payroll taxes)
    "CP11-300": (18400.00, 1410.00), "CP11-410": (96300.00, 7370.00), "CP11-420": (31200.00, 2390.00),
    "CP11-435": (6830.00, 520.00), "CP11-450": (38900.00, 2980.00), "CP11-900": (27600.00, 2110.00),
}
PAYROLL_CREDITS = [("21500000", "Net pay payable", 158640.00),
                   ("21600000", "Employee withholdings payable", 60590.00),
                   ("21700000", "Employer payroll taxes payable", 16780.00)]
# When HR moves the people off CP11-435, the payroll system recalculates the posting.
# The payroll system's re-run moves these totals (course values). Note what they reveal (see the topic).
REPOST_IF_FIXED = {"00100205": ("CP11-410", 4610.00, 350.00), "00100231": ("CP11-450", 2220.00, 170.00)}


def payroll_lines() -> list:
    by_cc = {cc: list(v) for cc, v in PAYROLL_BY_CC.items()}
    people = {p: cc for p, _, _, cc, _, _ in hr_people()}
    for p, (new_cc, wages, tax) in REPOST_IF_FIXED.items():
        if people[p] == new_cc:
            by_cc["CP11-435"][0] -= wages
            by_cc["CP11-435"][1] -= tax
            by_cc[new_cc][0] += wages
            by_cc[new_cc][1] += tax
    lines = []
    for cc in sorted(by_cc):
        wages, tax = (round(x, 2) for x in by_cc[cc])
        if wages or tax:
            lines.append((GL_WAGES, "Wages and salaries", cc, wages, 0.0))
            lines.append((GL_ER_TAX, "Employer payroll taxes", cc, tax, 0.0))
    lines += [(gl, name, "", 0.0, amount) for gl, name, amount in PAYROLL_CREDITS]
    return lines


def check_payroll() -> dict:
    lines = payroll_lines()
    debit = round(sum(l[3] for l in lines), 2)
    credit = round(sum(l[4] for l in lines), 2)
    bad = [l for l in lines if l[2] and not s4_valid(l[2], "2026-10-30")]
    return {"lines": lines, "debit": debit, "credit": credit, "bad": bad,
            "held": round(sum(l[3] for l in bad), 2), "posts": debit == credit and not bad}


# ------------------------------------------------------------ time (fictional)
# Time recorded by technicians, waiting to become confirmations on maintenance orders.
# (record, personnel number, date, order, work center, hours). MECH1 95.00 and ELEC1 105.00 USD/h.
RATES = {"MECH1": 95.00, "ELEC1": 105.00}
TIME_RECORDS = [
    ("T-5101", "00100117", "2026-10-26", "4000126", "MECH1", 6.0),
    ("T-5102", "00100142", "2026-10-26", "4000126", "ELEC1", 3.5),
    ("T-5103", "00100231", "2026-10-27", "4000126", "MECH1", 8.0),
    ("T-5104", "00100231", "2026-10-28", "4000126", "MECH1", 6.0),
    ("T-5105", "00100094", "2026-10-20", "4000126", "MECH1", 4.0),
]


def check_time(s4: dict) -> list:
    out = []
    for rec, p, day, order, wc, hours in TIME_RECORDS:
        cost = hours * RATES[wc]
        if p not in s4:
            out.append((rec, p, day, order, hours, cost, "blocked", "personnel number not in S/4HANA"))
        elif s4[p]["End"] and day > s4[p]["End"]:
            out.append((rec, p, day, order, hours, cost, "check", f"date after employment end {s4[p]['End']}"))
        else:
            out.append((rec, p, day, order, hours, cost, "confirmed", "activity allocation to the order"))
    return out


# ------------------------------------------------------------ travel and expenses (fictional)
# Expense reports approved in the expense system, waiting to post to CP10. (report, personnel number, purpose, cost center, USD)
EXPENSE_REPORTS = [
    ("ER-2026-0412", "00100117", "Crane inspection training, Chicago", "CP11-450", 642.18),
    ("ER-2026-0415", "00100094", "Seal supplier visit, 28 Sep - 2 Oct", "CP11-450", 2318.40),
    ("ER-2026-0419", "00100231", "Safety boots and induction travel", "CP11-435", 486.75),
]


def check_expenses(s4: dict) -> list:
    out = []
    people = {p: cc for p, _, _, cc, _, _ in hr_people()}
    for rep, p, purpose, cc, amount in EXPENSE_REPORTS:
        if p == "00100231" and p in HR_FIXES:
            cc = people[p]          # the expense system takes the default cost center from HR
        problems = []
        if p not in s4:
            problems.append("no employee business partner or supplier role for reimbursement")
        if not s4_valid(cc, AS_OF):
            problems.append(f"cost center {cc} closed")
        note = "; ".join(problems) if problems else (
            "employment ended: keep the supplier role payable until reimbursed"
            if p in s4 and s4[p]["End"] else "")
        out.append((rep, p, purpose, cc, amount, "blocked" if problems else "posts", note))
    return out


# ------------------------------------------------------------ the crossings check
def money(x: float) -> str:
    return f"{x:,.2f}"


def check_pack() -> None:
    try:
        org = json.loads((PACK / "org_units.json").read_text(encoding="utf-8"))
    except FileNotFoundError:
        sys.exit(f"Cannot read {PACK}. Run: python unit1b/make_calder_pack.py (Unit 1B setup).")
    codes = {c["CompanyCode"]: c for c in org["CompanyCodes"]}
    if set(codes) != {"CP10", "CP20"} or codes["CP20"]["System"] != "ECC":
        sys.exit("Company codes CP10 (S/4HANA) and CP20 (ECC) are missing from the pack. Run make_calder_pack.py again.")
    if not any(a["ControllingArea"] == "CP00" for a in org["ControllingAreas"]):
        sys.exit("Controlling area CP00 is missing from the pack. Run make_calder_pack.py again.")


def findings() -> list:
    """Each finding: (id, crossing, what, owner, root cause)."""
    s4, rejected = replicate()
    hr_cc = hr_cost_centers()
    found = []
    for cc, (_, vt) in sorted(hr_cc.items()):
        s4_vt = next(v for c, _, _, v in S4_COST_CENTERS if c == cc)
        if vt != s4_vt:
            users = [p for p, _, _, c, _, _ in hr_people() if c == cc]
            found.append(("cost centers S/4HANA -> HR",
                          f"{cc} closed in S/4HANA on {s4_vt}, still open in HR (last replicated "
                          f"{HR_CC_LAST_REPLICATED}); {len(users)} people assigned", "HR integration team",
                          "cost center replication"))
    for p, why in sorted(rejected.items()):
        found.append(("employees HR -> S/4HANA", f"{p} hired, not in S/4HANA: {why}",
                      "HR integration team", "cost center replication"))
    for p, rec in sorted(s4.items()):
        if not s4_valid(rec["CostCenter"], AS_OF) and not rec["End"]:
            found.append(("employees HR -> S/4HANA", f"{p} assigned to closed cost center {rec['CostCenter']}",
                          "HR, with the cost accountant", "cost center replication"))
    for p, valid_to in BUSINESS_USERS:
        if p in s4 and s4[p]["End"] and valid_to > s4[p]["End"]:
            found.append(("leaver -> access", f"{p} left on {s4[p]['End']}; S/4HANA business user still valid",
                          "Security administrator", "leaver process"))
    pay = check_payroll()
    if not pay["posts"]:
        ccs = sorted({l[2] for l in pay["bad"]})
        found.append(("payroll -> finance", f"October posting held: {money(pay['held'])} USD on {', '.join(ccs)}",
                      "Payroll, with the G/L accountant", "cost center replication"))
    for rec, p, day, order, hours, cost, status, why in check_time(s4):
        if status != "confirmed":
            root = "cost center replication" if status == "blocked" else "time entry"
            found.append(("time -> maintenance orders", f"{rec}: {hours:.1f} h by {p} on order {order} ({why})",
                          "Maintenance supervisor", root))
    for rep, p, purpose, cc, amount, status, why in check_expenses(s4):
        if status == "blocked":
            found.append(("expenses -> finance", f"{rep}: {money(amount)} USD for {p} ({why})",
                          "Travel and expense admin", "cost center replication"))
    return [(f"H{i}", *f) for i, f in enumerate(found, 1)]


def print_check() -> None:
    s4, _ = replicate()
    found = findings()
    print(f"Calder Pumpworks (fictional), CP10 people-data crossings on {AS_OF}")
    print(f"HR records: {len(HR_EMPLOYEES)}   in S/4HANA: {len(s4)}   findings: {len(found)}\n")
    for fid, crossing, what, owner, root in found:
        print(f"  {fid:<4}{crossing:<28}{what}")
        print(f"      owner: {owner:<34}root cause: {root}")
    roots = {}
    for f in found:
        roots[f[4]] = roots.get(f[4], 0) + 1
    print("\n  By root cause: " + ", ".join(f"{r} {n}" for r, n in sorted(roots.items(), key=lambda x: -x[1])))


def print_payroll() -> None:
    pay = check_payroll()
    print(f"October payroll posting document for CP10 (fictional), posting date 2026-10-30\n")
    print(f"  {'G/L':<10}{'Account':<32}{'Cost center':<13}{'Debit':>12}{'Credit':>12}  check")
    for gl, name, cc, d, c in pay["lines"]:
        flag = "" if not cc else ("ok" if s4_valid(cc, "2026-10-30") else "closed")
        print(f"  {gl:<10}{name:<32}{cc:<13}{money(d) if d else '':>12}{money(c) if c else '':>12}  {flag}")
    print(f"\n  Debits {money(pay['debit'])}   credits {money(pay['credit'])}   "
          f"{'balanced' if pay['debit'] == pay['credit'] else 'NOT balanced'}")
    if pay["posts"]:
        print("  Result: the document posts to CP10.")
    else:
        print(f"  Result: held. {money(pay['held'])} USD sits on a cost center that is closed in S/4HANA;")
        print("  in this scenario the whole document waits, so October payroll is missing from CP10's books.")


def print_time() -> None:
    s4, _ = replicate()
    rows = check_time(s4)
    print("Technician time for maintenance orders (fictional), October 2026\n")
    for rec, p, day, order, hours, cost, status, why in rows:
        print(f"  {rec:<8}{p:<10}{day:<12}{order:<9}{hours:>5.1f} h{money(cost):>10}  {status:<10}{why}")
    for status in ("confirmed", "blocked", "check"):
        h = sum(r[4] for r in rows if r[6] == status)
        c = sum(r[5] for r in rows if r[6] == status)
        print(f"\n  {status:<10}{h:>5.1f} h{money(c):>10} USD", end="")
    print("\n\n  Blocked hours are real work missing from the order's cost until the person exists in S/4HANA.")


def print_expenses() -> None:
    s4, _ = replicate()
    print("Expense reports waiting to post to CP10 (fictional)\n")
    for rep, p, purpose, cc, amount, status, why in check_expenses(s4):
        print(f"  {rep:<14}{p:<10}{cc:<10}{money(amount):>10}  {status:<8}{why}")


# ------------------------------------------------------------ AI grounding: what may cross
# Fields a planned maintenance scheduling assistant asked for, and the decision per field.
# allow: needed and low risk. pseudonymize: needed to join records, never shown as is. exclude: not needed or sensitive.
AI_FIELD_POLICY = [
    ("PersonnelNumber", "pseudonymize", "needed to join records; the assistant sees a token"),
    ("WorkCenter", "allow", "decides which orders a person can work on"),
    ("SkillTags", "allow", "job-related qualifications"),
    ("PlannedWorkingHours", "allow", "capacity per day"),
    ("AvailableOnDay", "allow", "yes or no per day, derived from absences"),
    ("AbsenceType", "exclude", "can reveal health or family matters; availability is enough"),
    ("CostCenter", "exclude", "not needed to schedule"),
    ("PayGrade", "exclude", "pay data; never needed to schedule"),
    ("HomeAddress", "exclude", "personal data; not needed"),
    ("DateOfBirth", "exclude", "personal data; not needed"),
    ("BankAccount", "exclude", "payment data; not needed"),
]
SKILLS = {"00100094": ["hydraulics"], "00100117": ["hydraulics", "crane inspection"],
          "00100142": ["electrical", "PLC"], "00100231": ["hydraulics"]}
# Absences in the HR system for the overhaul week 9-13 Nov (the type stays in HR).
ABSENCES = {("00100117", "2026-11-10"): "sick leave",
            ("00100142", "2026-11-12"): "vacation", ("00100142", "2026-11-13"): "vacation"}
WEEK = ["2026-11-09", "2026-11-10", "2026-11-11", "2026-11-12", "2026-11-13"]
# The pseudonym key comes from the environment. The course default keeps your output
# identical to the topic; a real system reads it from a secret store and has no default.
PSEUDONYM_KEY = os.environ.get("CALDER_PSEUDONYM_KEY", "calder-course-only").encode()


def token(p: str) -> str:
    return "T-" + hmac.new(PSEUDONYM_KEY, p.encode(), hashlib.sha256).hexdigest()[:8]


def ai_view() -> list:
    s4, _ = replicate()
    rows = []
    for p, rec in sorted(s4.items()):
        if rec["WorkCenter"] not in RATES or (rec["End"] and rec["End"] < WEEK[0]):
            continue
        rows.append({"Person": token(p), "WorkCenter": rec["WorkCenter"], "SkillTags": SKILLS.get(p, []),
                     "PlannedWorkingHours": 8.0,
                     "AvailableOnDay": {d[5:]: (p, d) not in ABSENCES for d in WEEK}})
    return rows


def print_ai() -> None:
    print("Field decisions for the maintenance scheduling assistant (course policy)\n")
    for field, decision, why in AI_FIELD_POLICY:
        print(f"  {field:<22}{decision:<14}{why}")
    rows = ai_view()
    print(f"\nWhat the assistant receives for the overhaul week, {len(rows)} technicians:\n")
    for r in rows:
        days = " ".join(f"{d}:{'yes' if ok else 'no '}" for d, ok in r["AvailableOnDay"].items())
        print(f"  {r['Person']}  {r['WorkCenter']:<7}{', '.join(r['SkillTags']):<30}{days}")
    print("\n  No names, no absence types, no pay. People not yet in S/4HANA aren't offered.")


# ------------------------------------------------------------ report
def write_report() -> Path:
    found = findings()
    pay = check_payroll()
    lines = [f"# Calder people-data crossings, {AS_OF}", "",
             "Fictional company and data, made up for the course. Not from an SAP system.", "",
             "```mermaid", "flowchart LR",
             "  S4[S/4HANA CP10] -->|cost centers| HR[HR system]",
             "  HR -->|employees| S4",
             "  HR --> PY[Payroll]",
             "  PY -->|posting document| S4",
             "  TM[Time records] -->|confirmations| S4",
             "  EX[Expense system] -->|postings| S4",
             "```", "",
             "| ID | Crossing | Finding | Owner | Root cause |", "| --- | --- | --- | --- | --- |"]
    lines += [f"| {f[0]} | {f[1]} | {f[2]} | {f[3]} | {f[4]} |" for f in found]
    lines += ["", f"Payroll posting: debits {money(pay['debit'])}, credits {money(pay['credit'])}, "
              f"{'posts' if pay['posts'] else 'held: ' + money(pay['held']) + ' USD'}.", ""]
    out = HERE / "calder_people_crossings.md"
    out.write_text("\n".join(lines) + "\n", encoding="utf-8")
    return out


# ------------------------------------------------------------ sandbox (SAP demo data, not Calder's)
def read_env_key(name: str) -> str:
    value = os.environ.get(name, "")
    env = Path.cwd() / ".env"
    if not value and env.exists():
        for line in env.read_text(encoding="utf-8").splitlines():
            if line.strip().startswith(f"{name}="):
                value = line.split("=", 1)[1].strip().strip('"').strip("'")
    return value


def sandbox() -> None:
    key = read_env_key("SAP_API_KEY")
    if not key:
        sys.exit("SAP_API_KEY is not set. Add it to .env (course setup) or skip this step.")
    base = ("https://sandbox.api.sap.com/s4hanacloud/sap/opu/odata/sap/"
            "API_MANAGE_WORKFORCE_TIMESHEET/TimeSheetEntryCollection")
    # Minimization in practice: no PersonWorkAgreement, no notes. Only what a cost view needs.
    query = urllib.parse.urlencode({"$top": "50", "$format": "json",
                                    "$select": "TimeSheetRecord,CompanyCode,TimeSheetDate,"
                                               "TimeSheetStatus,TimeSheetDataFields"})
    req = urllib.request.Request(f"{base}?{query}", headers={"APIKey": key, "Accept": "application/json"})
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            data = json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as err:
        sys.exit(f"Sandbox answered HTTP {err.code}. Check the key, or try again later.")
    except (urllib.error.URLError, TimeoutError) as err:
        sys.exit(f"Cannot reach the sandbox ({err}). Check the network or proxy.")
    items = data.get("d", {}).get("results", [])
    by_receiver = {}
    for it in items:
        f = it.get("TimeSheetDataFields") or {}
        receiver = f.get("ReceiverCostCenter") or f.get("WBSElement") or f.get("InternalOrder") or "(none)"
        key2 = (f.get("ActivityType") or "-", receiver)
        by_receiver[key2] = by_receiver.get(key2, 0.0) + float(f.get("RecordedHours") or 0)
    print(f"SAP sandbox: {len(items)} timesheet records read (SAP demo data, not Calder's)\n")
    for (activity, receiver), hours in sorted(by_receiver.items(), key=lambda x: -x[1])[:8]:
        print(f"  activity type {activity:<12} receiver {receiver:<26}{hours:>8.2f} h")
    print("\n  Person fields were not requested: a cost view doesn't need them.")


# ------------------------------------------------------------ main
def main() -> None:
    parser = argparse.ArgumentParser(description="Hire to retire crossings at Calder (fictional data).")
    parser.add_argument("--payroll", action="store_true", help="October payroll posting document")
    parser.add_argument("--time", action="store_true", help="technician time for maintenance orders")
    parser.add_argument("--expenses", action="store_true", help="expense reports waiting to post")
    parser.add_argument("--ai", action="store_true", help="field decisions and the minimized view")
    parser.add_argument("--write", action="store_true", help="write unit1b/calder_people_crossings.md")
    parser.add_argument("--sandbox", action="store_true", help="read timesheet records from SAP's sandbox")
    args = parser.parse_args()
    if args.sandbox:
        sandbox()
        return
    check_pack()
    if args.payroll:
        print_payroll()
    elif args.time:
        print_time()
    elif args.expenses:
        print_expenses()
    elif args.ai:
        print_ai()
    else:
        print_check()
        if args.write:
            print(f"\nwrote unit1b/{write_report().name}")


if __name__ == "__main__":
    main()

Step 4: Run the crossings check

  1. Run:

    • Windows (PowerShell):

      python unit1b\hire_to_retire.py
    • macOS / Linux:

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

Calder Pumpworks (fictional), CP10 people-data crossings on 2026-11-02
HR records: 9   in S/4HANA: 8   findings: 9

  H1  cost centers S/4HANA -> HR  CP11-435 closed in S/4HANA on 2026-09-30, still open in HR (last replicated 2026-09-14); 2 people assigned
      owner: HR integration team               root cause: cost center replication
  H2  employees HR -> S/4HANA     00100231 hired, not in S/4HANA: cost center CP11-435 not valid on 2026-10-19
      owner: HR integration team               root cause: cost center replication
  H3  employees HR -> S/4HANA     00100205 assigned to closed cost center CP11-435
      owner: HR, with the cost accountant      root cause: cost center replication
  H4  leaver -> access            00100094 left on 2026-10-16; S/4HANA business user still valid
      owner: Security administrator            root cause: leaver process
  H5  payroll -> finance          October posting held: 7,350.00 USD on CP11-435
      owner: Payroll, with the G/L accountant  root cause: cost center replication
  H6  time -> maintenance orders  T-5103: 8.0 h by 00100231 on order 4000126 (personnel number not in S/4HANA)
      owner: Maintenance supervisor            root cause: cost center replication
  H7  time -> maintenance orders  T-5104: 6.0 h by 00100231 on order 4000126 (personnel number not in S/4HANA)
      owner: Maintenance supervisor            root cause: cost center replication
  H8  time -> maintenance orders  T-5105: 4.0 h by 00100094 on order 4000126 (date after employment end 2026-10-16)
      owner: Maintenance supervisor            root cause: time entry
  H9  expenses -> finance         ER-2026-0419: 486.75 USD for 00100231 (no employee business partner or supplier role for reimbursement; cost center CP11-435 closed)
      owner: Travel and expense admin          root cause: cost center replication

  By root cause: cost center replication 7, leaver process 1, time entry 1
  1. Read the last line first. Seven of nine findings share one root cause: HR still thinks CP11-435 is open because the cost center feed last ran on 14 September. The other two are separate problems: a leaver's user and hours keyed after a leaving date.

Step 5: Check the payroll posting

  1. Run the script with --payroll (Windows: python unit1b\hire_to_retire.py --payroll; macOS/Linux: python unit1b/hire_to_retire.py --payroll).
  2. You should see:
October payroll posting document for CP10 (fictional), posting date 2026-10-30

  G/L       Account                         Cost center         Debit      Credit  check
  61100000  Wages and salaries              CP11-300        18,400.00              ok
  61200000  Employer payroll taxes          CP11-300         1,410.00              ok
  61100000  Wages and salaries              CP11-410        96,300.00              ok
  61200000  Employer payroll taxes          CP11-410         7,370.00              ok
  61100000  Wages and salaries              CP11-420        31,200.00              ok
  61200000  Employer payroll taxes          CP11-420         2,390.00              ok
  61100000  Wages and salaries              CP11-435         6,830.00              closed
  61200000  Employer payroll taxes          CP11-435           520.00              closed
  61100000  Wages and salaries              CP11-450        38,900.00              ok
  61200000  Employer payroll taxes          CP11-450         2,980.00              ok
  61100000  Wages and salaries              CP11-900        27,600.00              ok
  61200000  Employer payroll taxes          CP11-900         2,110.00              ok
  21500000  Net pay payable                                            158,640.00  
  21600000  Employee withholdings payable                               60,590.00  
  21700000  Employer payroll taxes payable                              16,780.00  

  Debits 236,010.00   credits 236,010.00   balanced
  Result: held. 7,350.00 USD sits on a cost center that is closed in S/4HANA;
  in this scenario the whole document waits, so October payroll is missing from CP10's books.

The document balances; it is held only because two lines name a closed cost center. The script checks each cost center against its validity on the posting date, the same test the ledger applies. In a real system, the error comes back from the posting, and payroll must correct the assignment and post again.

Step 6: Check time and expenses

  1. Run with --time:

    • Windows (PowerShell):

      python unit1b\hire_to_retire.py --time
    • macOS / Linux:

      python unit1b/hire_to_retire.py --time
  2. You should see:

Technician time for maintenance orders (fictional), October 2026

  T-5101  00100117  2026-10-26  4000126    6.0 h    570.00  confirmed activity allocation to the order
  T-5102  00100142  2026-10-26  4000126    3.5 h    367.50  confirmed activity allocation to the order
  T-5103  00100231  2026-10-27  4000126    8.0 h    760.00  blocked   personnel number not in S/4HANA
  T-5104  00100231  2026-10-28  4000126    6.0 h    570.00  blocked   personnel number not in S/4HANA
  T-5105  00100094  2026-10-20  4000126    4.0 h    380.00  check     date after employment end 2026-10-16

  confirmed   9.5 h    937.50 USD
  blocked    14.0 h  1,330.00 USD
  check       4.0 h    380.00 USD

  Blocked hours are real work missing from the order's cost until the person exists in S/4HANA.
  1. Run with --expenses the same way. You should see:
Expense reports waiting to post to CP10 (fictional)

  ER-2026-0412  00100117  CP11-450      642.18  posts   
  ER-2026-0415  00100094  CP11-450    2,318.40  posts   employment ended: keep the supplier role payable until reimbursed
  ER-2026-0419  00100231  CP11-435      486.75  blocked no employee business partner or supplier role for reimbursement; cost center CP11-435 closed

The leaver's expense report posts on purpose. He spent the money while employed, so Calder still owes it. Blocking a leaver's supplier role on the last day would stop a legitimate payment.

Step 7: See what a scheduling assistant may receive

The overhaul of the test bench, order 4000123, runs 9 to 11 November. A planned assistant would propose which technicians to schedule. It asked for eleven HR fields.

  1. Run with --ai (Windows: python unit1b\hire_to_retire.py --ai; macOS/Linux: python unit1b/hire_to_retire.py --ai).
  2. You should see:
Field decisions for the maintenance scheduling assistant (course policy)

  PersonnelNumber       pseudonymize  needed to join records; the assistant sees a token
  WorkCenter            allow         decides which orders a person can work on
  SkillTags             allow         job-related qualifications
  PlannedWorkingHours   allow         capacity per day
  AvailableOnDay        allow         yes or no per day, derived from absences
  AbsenceType           exclude       can reveal health or family matters; availability is enough
  CostCenter            exclude       not needed to schedule
  PayGrade              exclude       pay data; never needed to schedule
  HomeAddress           exclude       personal data; not needed
  DateOfBirth           exclude       personal data; not needed
  BankAccount           exclude       payment data; not needed

What the assistant receives for the overhaul week, 2 technicians:

  T-dd79bd56  MECH1  hydraulics, crane inspection  11-09:yes 11-10:no  11-11:yes 11-12:yes 11-13:yes
  T-c4b1170a  ELEC1  electrical, PLC               11-09:yes 11-10:yes 11-11:yes 11-12:no  11-13:no 

  No names, no absence types, no pay. People not yet in S/4HANA aren't offered.

The assistant gets work center, skills and a yes or no per day. It never learns that one "no" is sick leave and two are vacation, and it sees tokens, not personnel numbers. The tokens come from a keyed hash (hmac); the same person always gets the same token, so the assistant's proposals can be mapped back in S/4HANA by someone allowed to. In the script the key comes from the environment variable CALDER_PSEUDONYM_KEY, with a course default so your output matches. A real system keeps the key in a secret store, with no default in the code: personnel numbers have only eight digits, so anyone who knows the key can hash every possible number and reverse every token in seconds.

The new technician isn't offered, because the assistant reads S/4HANA, where he doesn't exist yet. That is one more cost of the stopped feed.

Step 8: Write the crossings report

  1. Run with --write (Windows: python unit1b\hire_to_retire.py --write; macOS/Linux: python unit1b/hire_to_retire.py --write).
  2. The last line should read wrote unit1b/calder_people_crossings.md. Open it in VS Code and press Ctrl+Shift+V (Windows, Linux) or Cmd+Shift+V (macOS) to preview it: a diagram of the crossings, the findings table and the payroll result.

Step 9 (optional): Read timesheet records from SAP's sandbox

This step reads SAP's demo data, not Calder's. It needs your sandbox key and the network.

  1. Make sure .env in the course folder has a line SAP_API_KEY="...".

  2. Run:

    • Windows (PowerShell):

      python unit1b\hire_to_retire.py --sandbox
    • macOS / Linux:

      python unit1b/hire_to_retire.py --sandbox
  3. The output has this shape. Values in angle brackets come from SAP's demo data and change over time:

SAP sandbox: <n> timesheet records read (SAP demo data, not Calder's)

  activity type <type>        receiver <cost center, WBS or order>      <hours> h

  Person fields were not requested: a cost view doesn't need them.

The script reads up to 50 records from API_MANAGE_WORKFORCE_TIMESHEET and sums hours by activity type and receiver. Its $select leaves out PersonWorkAgreement and notes: minimization at the API call. A valid "empty" result is 0 timesheet records read. The sandbox is read-only.

Step 10: Save your work in Git

git add unit1b/hire_to_retire.py unit1b/calder_people_crossings.md
git commit -m "Unit 1B: hire-to-retire crossings check, payroll posting and minimized AI view for Calder"

What each part of the code does

Part What it does
CC_REPLICATION_RUN, HR_FIXES The two fixes the exercise applies: rerun the cost center feed, and move people off the closed cost center
S4_COST_CENTERS, hr_cost_centers(), s4_valid() Cost centers in S/4HANA with validity, and the stale copy in HR
HR_EMPLOYEES, replicate() People in HR, and which of them S/4HANA accepts as employee and employment business partners
PAYROLL_BY_CC, payroll_lines(), check_payroll() The payroll posting by cost center: balance check and closed cost centers
TIME_RECORDS, check_time() Hours waiting to become confirmations, blocked or flagged by the person's record
EXPENSE_REPORTS, check_expenses() Expense reports, checked for a payee and a valid cost center
findings(), print_check() All findings with owner and root cause
AI_FIELD_POLICY, token(), ai_view() Field decisions, pseudonyms and the minimized record a scheduling assistant receives
sandbox() Reads up to 50 sandbox timesheet records without person fields

If something goes wrong

What you see What it means What to do
python is not recognized / command not found Python isn't on the path, or the terminal opened before installing Follow the Python step in Set up your computer, then open a new terminal
Cannot read ...calder_pack The pack wasn't written, or you ran from the wrong folder Run Step 2 from the orchestrate-course folder
Company codes CP10 ... are missing or Controlling area CP00 is missing The pack was edited Run Step 2 again
KeyError with a personnel number A personnel number in HR_FIXES or REPOST_IF_FIXED isn't in HR_EMPLOYEES Check the spelling, with leading zeros
Your output differs from the one shown Values in the script were changed Copy the script again, or compare your edits
SAP_API_KEY is not set No key in the environment or .env Add it as in the course setup, or skip Step 9
Sandbox answered HTTP 401 The key is wrong or expired Copy the key again from the Business Accelerator Hub
Sandbox answered HTTP 404 The API isn't in the sandbox under that path Check the API's sandbox URL on the Business Accelerator Hub, or skip Step 9
Cannot reach the sandbox No network, or a company proxy blocks it Try another network, or stay with Steps 1 to 8
ModuleNotFoundError You changed the script to use an outside library The script needs only built-in modules; copy it again

Architecture view

Where it sits. At Calder (fictional), Employee Central holds people for CP10; CP20 keeps HR and payroll inside ECC until its move to S/4HANA. CP10's S/4HANA system owns cost centers and receives workforce records. Payroll and the expense system post into CP10. Any AI tool reads S/4HANA's derived facts, not the HR system.

flowchart LR
  S4[S/4HANA CP10<br/>cost centers, orders,<br/>ledger] -->|cost centers| MDI[Replication<br/>middleware]
  MDI --> EC[Cloud HR system<br/>people]
  EC -->|workforce| MDI
  MDI --> S4
  EC --> PY[Payroll]
  PY -->|posting document| S4
  CX[Expense system] -->|journal entries| S4
  ECC[ECC CP20<br/>HR, payroll,<br/>finance] -.->|later: migration| EC
  S4 -->|availability,<br/>skills, tokens| AI[Scheduling<br/>assistant]

Non-functional requirements that decide the design

Requirement Typical target If missed
Privacy and minimization Each crossing carries a documented field list; no individual pay in finance unless required Personal data spreads to systems and people with no need
Replication freshness Hires and transfers in S/4HANA within a day; cost center changes in HR before the next pay run New hires can't work or be paid; postings fail
Monitoring Every feed has an owner and an alert on failure A stopped feed is found at month end
Leaver timeliness Access ends on the last day Audit findings; security incidents
Retention and deletion Personal data blocked after its purpose ends and deleted after its retention period Legal exposure; data kept forever
Audit Who changed a person's cost assignment and when Payroll postings can't be explained

Integration and identity. The packaged Employee Central integrations use middleware: SAP Master Data Integration for S/4HANA Cloud Public Edition (SAP Learning), and web services configured in S/4HANA for SAP HCM targets (SAP Learning). The person ID must match across every channel (SAP KBA 2813743). The same person is also a business user who logs on; the leaver event must reach user administration as well as the business partner. Any assistant reading people data should use the user's own identity and authorizations (grounding with authorizations). Integration patterns between systems are covered in how SAP systems talk.

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

  • Context: Calder's payroll posts one document per pay run to CP10. Some cost centers have one or two people, so totals by cost center can reveal an individual's pay. Finance asks for detail by person to explain labor variances.
  • Options: (a) post one line per employee; (b) post totals by cost center and account, with detail kept in payroll; (c) totals by cost center, plus a restricted payroll report for the few people who explain variances.
  • Decision: (c). The ledger carries totals; detail stays in payroll behind payroll authorizations, and controlling reports hide cost centers with fewer than a set number of people.
  • Consequences: Variance explanations need a payroll analyst. A cost center with one person still shows that person's cost, so its access is restricted. Any AI that explains labor cost reads totals only.

What a design review will ask

  1. Which system is the system of record for each field that crosses, and what is the field list per crossing?
  2. Who is alerted when a feed stops, and how fast?
  3. What happens in S/4HANA on a hire, a transfer and a leave, on the effective date?
  4. Can anyone in finance see one person's pay, through a posting, a report or an AI tool?
  5. When are a leaver's records blocked and deleted, and does that cover copies outside SAP?

Where AI fits, and where it doesn't

Crossing Pattern Fit Note
Feed monitoring Plain automation is enough Weak for AI A stopped job and an invalid cost center are queries, as in Step 4
Replication errors Retrieve and explain Moderate Explain an error message and suggest the fix from documentation; a person corrects the record
Time records Predict (detect) Moderate Flag hours keyed against the wrong person or after a leaving date; the supervisor corrects them
Expense reports Extract and detect Strong Read receipts and flag policy breaks; SAP Concur is the usual home for this
Payroll posting Plain automation is enough Weak for AI Rules and balances; AI adds risk here, not value
Scheduling technicians Predict and act (propose) Moderate Uses derived availability and skills only, as in Step 7; the planner assigns
Labor cost commentary Explain Moderate From totals by cost center, never from individual pay
Hiring, promotion, performance Predict Only with legal review Listed as high-risk uses under the EU AI Act; see the EU AI Act for SAP customers

Why HR data is usually kept out of AI grounding. A retrieval index or an agent's context is one more copy of the data, readable by anyone who can ask the assistant. HR records hold the data that does the most harm when exposed: health through absence types, pay, bank details, family and disciplinary matters. Most business questions need a derived fact instead. Data security and PII shows how to minimize, mask and erase personal data in an AI assistant, and agent permissions shows how to keep an agent within the user's own authorizations. The line here: AI reads derived facts and proposes; HR changes people records, payroll posts, and a person approves every change.

Pitfalls

  • Closing a cost center without asking HR. Check who is assigned to it in the HR and payroll systems first, and plan the move.
  • No owner for the replication monitor. Errors pile up quietly until payroll or the close finds them.
  • Reusing positions across functions. A position carries a cost center; reusing an old one brings its old assignment.
  • Running two replication channels at once. Mismatched person IDs create duplicate employees.
  • Posting payroll by person by default. It spreads pay data into the ledger, reports and every tool that reads it.
  • Blocking everything on a leaving date. End access the same day, but keep paying what is owed and keep records for their retention period.
  • Grounding an assistant on HR tables "for context". Give it a derived fact through a narrow view, or nothing.
  • Assuming CP20 works like CP10. In ECC, HR and finance may share one system, with employee vendors instead of business partners; plan the move to S/4HANA around that.

Exercise

Fix the stopped feed at Calder, prove what it repairs, and write the people part of the capstone note.

  1. Run python unit1b/hire_to_retire.py --write (Windows: python unit1b\hire_to_retire.py --write). Copy unit1b/calder_people_crossings.md to unit1b/calder_people_actions.md.
  2. In the copy, add a column Action to the findings table. For each finding write one sentence: what the owner does today.
  3. In hire_to_retire.py, change CC_REPLICATION_RUN = False to CC_REPLICATION_RUN = True. This is the cost center feed running again.
  4. Change HR_FIXES = {} to HR_FIXES = {"00100205": "CP11-410", "00100231": "CP11-450"}. This is HR moving the rework technician to assembly and the new technician to maintenance.
  5. Run the script without options, then with --payroll, --time, --expenses and --ai. Note the number of findings, the payroll result, the hours now confirmed on order 4000126, the expense report and how many technicians the assistant now sees.
  6. Change both edits back.
  7. In the action note, add a heading Fixes tested with four lines: what is left in the findings, what payroll now posts to CP11-410 and CP11-450, what order 4000126 now carries, and the assistant's view.
  8. Add a heading Privacy note with two lines: which payroll lines moved when the two people changed cost center, and why that shows one person's pay to anyone who can read the posting.
  9. Preview the file and commit it with Git.

Done when: unit1b/calder_people_actions.md has the nine-line findings table with Action filled in, a Fixes tested section that says two findings remain (the leaver's user and the 4.0 hours after his leaving date), the payroll document posts with 100,910.00 USD wages on CP11-410 and 41,120.00 on CP11-450, order 4000126 carries 23.5 confirmed hours (2,267.50 USD), the expense report ER-2026-0419 posts to CP11-450, and the assistant sees three technicians, plus a Privacy note. Keep it: the capstone at the end of Unit 1B traces one exception across the whole enterprise, and the technicians' availability is its people part.

Check yourself

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

1Why did S/4HANA reject the new technician's employee record in Calder's scenario?

Answer: D. HR assigned him to CP11-435, which S/4HANA closed on 30 September. The HR system didn't know, because the cost center feed had stopped.

2In S/4HANA on-premise, what does an employee become so that logistics and finance processes can use them?

Answer: A. From S/4HANA 2020, each employee is synchronized to business partners with roles BUP003 and BUP010; a supplier role is optional. PRAA employee vendors belong to SAP ERP and are no longer valid.

3The payroll posting balances to the cent but is still held. What does the script check that explains this?

Answer: D. Two lines name CP11-435, closed on 30 September. A balanced document still fails if an account assignment isn't valid when it posts.

4Maintenance hours reach order 4000126 through which interface, given the timesheet API's receivers?

Answer: A. The timesheet model's receivers are cost center, WBS element, internal order and purchase order. Maintenance orders take hours as confirmations, which carry the personnel number and the hours.

5What would you do if finance asked to post payroll with one line per employee to explain labor variances?

Answer: B. Per-person lines put individual pay in the ledger and every report and tool that reads it. Totals by cost center serve the close; detail stays in payroll behind its own authorizations. Personnel numbers still identify people.

6The scheduling assistant gets "unavailable on 10 November" but not the absence type. Why?

Answer: A. Data minimization: the decision needs only whether someone can work. Sick leave is health data, so it stays in the HR system.

7A leaver's expense report for a trip while employed arrives after his last day. What should happen?

Answer: D. Calder owes the money for work done while employed. Access ends on the last day, but the payee record must stay payable until the debt is settled.

Sources

Sign in to track your progress

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

or

Tell us a little about you

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

SAP areas you work in