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.

Record to report, end to end

See the period-end close as a chain of tasks fed by every module, find what blocks it at Calder, and where AI helps with accruals, matching and commentary.

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

The 60-second version

Record to report is how a company turns a month of business into numbers it can sign. Every other process records as it goes: a goods issue posts cost, an invoice posts revenue, a confirmation posts labor. Record to report finishes the job at period end, in a set order:

  • Fill the gaps. Book costs that are real but not yet invoiced, such as a service that was done in October and will be billed in November. These are accruals.
  • Move costs to where they belong. Settle production and maintenance orders, and allocate shared costs, such as utilities, to the departments that used them.
  • Prove the balances. Reconcile the accounts that should net out: goods received against invoices, one Calder company against the other.
  • Report. Look at margin by product and customer, explain what moved, close the period, and hand the figures to the group.

Three ideas carry this topic:

  • The close is a chain, not a list. You can't allocate a cost center before the orders that charge it have settled. One late task early in the chain moves the last one.
  • Most close problems start outside finance. An equipment record without a cost center, an unapproved service entry sheet or a billing block shows up at period end as a finance problem.
  • Explaining matters as much as balancing. A margin that drops 14 points needs a reason, backed by documents, before anyone signs.

Why it matters to the business

A slow or shaky close costs twice. Leaders decide later, on figures they don't fully trust, and the finance team spends its first week of every month chasing other departments.

Here is Tuesday morning, 3 November 2026, the second working day of the October close at Calder Pumpworks, the fictional pump maker this unit follows. The controller of the US company (CP10) sees this:

What the close board shows What it means Who acts
Maintenance order settlement is blocked A September repair on the air compressor can't settle, because the compressor has no cost center Master data, with the cost accountant
Overhead allocation and the margin review wait on it Utilities can't be shared out until the repair cost has arrived Cost accountant, controller
CP10's local close slips from day 3 to day 4 One missing field in a maintenance record moves the whole calendar Controller
The crane inspection was done in October but isn't approved Without an accrual, October's maintenance cost looks 3,800 USD too low The requester, then the G/L accountant
The German company (CP20) shows 13,400 USD more owed by CP10 than CP10 has booked Impellers shipped on 30 October are still on the truck Intercompany accountant, both sides
ES-300 margin fell from 29% to 15% One pump was scrapped at the pressure test Controller, with production

The business decision is not "close faster". It is which upstream steps must be clean before the close starts, and who owns each one, because finance can't fix a cost center, approve a service or ship an impeller.

How SAP does it

In SAP S/4HANA, most postings land in one table, the Universal Journal, so finance and controlling read the same numbers (Document flow and how logistics becomes finance explains how they get there). The close runs as a set of standard jobs and apps on top of it. As of October 2026, SAP's training covers these for S/4HANA:

  • Accruals Management proposes accruals for purchase orders from planned minus actual cost, with an optional review by the purchase order owner, and posts manual accruals such as an annual audit fee month by month (SAP Learning).
  • GR/IR reconciliation apps give accountants a worklist of goods receipts and invoices that don't match. Machine-learning proposals for next step, priority and root cause need a separate subscription (SAP Learning).
  • Intercompany Matching and Reconciliation (ICMR) matches the two sides of intercompany transactions with rules, tolerances and reason codes (SAP Learning).
  • Margin analysis is S/4HANA's profitability analysis, stored in the Universal Journal. The older costing-based variant, with its own tables, is not available in the cloud editions (SAP Learning).
  • SAP S/4HANA Finance for group reporting consolidates the companies: it releases the S/4HANA data, takes uploads from companies on other systems, translates currencies and eliminates intercompany balances (SAP Learning).

To run the close as a plan, SAP offers SAP Advanced Financial Closing, an application on SAP BTP. It holds task lists with owners, dependencies and approvals, and starts jobs in connected SAP S/4HANA Cloud, SAP S/4HANA and SAP ERP systems (SAP Learning). It is a separate product; check licensing with SAP.

For AI, SAP's site describes a Financial Closing Assistant that orchestrates close agents, among them an Accounting Accruals Agent and an Intercompany Matching and Reconciliation Agent (SAP). At SAP Sapphire in May 2026, SAP said general availability of the assistant was planned for Q2 2026 (SAP). That date has passed, and as of 4 November 2026 the use-case page gives no release date, edition or region, so treat the Sapphire plan as unconfirmed. As of 4 November 2026, the Accounting Accruals Agent's page lists SAP S/4HANA Cloud Public Edition, requires AI Units and offers beta registration without stating general availability (SAP). Confirm what is live for your edition and region before you plan on it.

The process on one page

flowchart LR
  L[Logistics<br/>cut-off] --> B[Billing<br/>and freight]
  L --> A[Accruals,<br/>GR/IR review]
  L --> S[Order<br/>settlement]
  A --> AL[Allocations]
  S --> AL
  AL --> M[Margin review,<br/>commentary]
  B --> M
  A --> R[Reconciliations,<br/>intercompany]
  M --> C[Close period]
  R --> C
  C --> G[Group<br/>reporting]
Step Team Document created Posts to finance?
Logistics cut-off Warehouse, purchasing, production Last goods movements and confirmations of the month Yes, as usual
Billing and freight Billing, freight settlement Customer invoices, freight settlement documents Yes: revenue, freight cost
Accruals General ledger accountants Accrual postings (reversed or replaced later) Yes: cost and liability for the period
GR/IR review Accounts payable Clarified items, write-offs where allowed Only for write-offs
Order settlement Cost accounting Settlement documents for production and maintenance orders Yes: orders credited, receivers debited
Allocations Cost accounting Allocation documents Yes: between cost centers or to margin
Reconciliations Accounting, intercompany team Reconciliation status, reason codes, adjustments Only for adjustments
Margin review Controller Commentary No
Close period General ledger Closed posting period No
Group reporting Group accountants Consolidation journal entries In the group ledger only

Questions to ask

  1. On which working day does each company close today, and which task finished last in each of the last six closes?
  2. Which close tasks wait on another department, and is that dependency written down with an owner?
  3. How large is the GR/IR balance, how old is it, and who chases it before period end?
  4. Which costs are real in the month but not yet invoiced, and how is each accrual estimated and approved?
  5. How much intercompany difference is left at group close, and how much of it is goods in transit or missing trading partners?
  6. When margin moves, how long does it take to explain, and does the explanation point to documents?
  7. If an AI tool proposes accruals or writes commentary, who approves each one, and is that logged?

Common misconceptions

  • "The close is finance's job." Finance runs it, but most of its inputs come from logistics, maintenance and sales. The fix for a blocked settlement is usually a master data change.
  • "The Universal Journal makes reconciliation unnecessary." It removes the reconciliation between finance and controlling inside S/4HANA. It doesn't match goods to invoices, one company to another, or SAP to other systems.
  • "An accrual is a guess." It is an estimate with a basis: a purchase order, a contract, last months' bills. Each should be traceable and reviewed.
  • "Intercompany differences mean someone made a mistake." Many are timing: goods shipped by one company and not yet received by the other. The reason matters as much as the amount.
  • "An agent can close the books." It can propose accruals, match items and draft commentary. A person approves postings and signs the figures.

Key terms

All course terms, A to Z, in the glossary

  • Period-end close: the tasks that finish a month's accounting so the figures can be reported.
  • Accrual: a cost or revenue booked in the period it belongs to, before the invoice arrives or is issued.
  • GR/IR clearing account: the account that holds goods received but not invoiced, and invoices for goods not received.
  • Settlement: moving an order's costs to their final receiver, such as a cost center, a product or a fixed asset.
  • Allocation: sharing a cost center's costs out to others by a key, such as floor space or energy use.
  • Trading partner: the company on the other side of an intercompany posting; matching depends on it.
  • Margin analysis: S/4HANA's profitability reporting by product, customer and other characteristics.
  • Consolidation: combining several companies' figures into group figures, without what they sold to each other.
  • Close calendar: the plan of tasks by working day (WD1, WD2) after the month ends.

Check yourself

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

1Calder's local close slips a day because a maintenance order can't settle. Where does the fix belong?

Answer: B. The order can't settle because the equipment has no cost center to receive the cost. Fixing the record lets the order settle normally; a manual entry hides the cause and repeats next month.

2Why does overhead allocation wait for maintenance order settlement?

Answer: C. The close is a chain. If the cycle runs before the repair cost reaches the utilities cost center, that cost stays behind and never reaches the products this month.

3The crane inspection was done in October, but its service entry sheet isn't approved. What should October's books show?

Answer: C. The cost belongs to October because the work was done then. An accrual books it now, based on the purchase order, so October's maintenance cost isn't understated.

4CP20 shows 13,400 USD more owed by CP10 than CP10 has booked. The goods left Augsburg on 30 October. What is the likely reason?

Answer: A. CP20 billed when it shipped; CP10 books when the goods and invoice arrive. The difference is real but explainable, and the reconciliation should record it with a timing reason, not chase an error.

5ES-300 margin fell 14 points. What should the controller have before signing the commentary?

Answer: D. A swing needs an explanation that traces to documents, here a scrapped pump on one production order. The numbers come from the postings, so anyone can check them.

6A vendor offers an agent that proposes accruals from past bills and posts them at month end. What do you insist on?

Answer: B. Proposals save time; postings change the books. A person accepts, adjusts or rejects each one, with a record, which is also how SAP describes its own accruals agent.

7Calder wants one plan for the close across CP10 on S/4HANA and CP20 on ECC, with owners and dependencies. Which SAP offering fits?

Answer: D. Advanced Financial Closing holds task lists with owners, dependencies and approvals and connects to S/4HANA and SAP ERP systems. The others are tasks inside the close, not the plan for it.
Deep layer · 40 min read

Mental model

Think of the close as a build that compiles every module's work into one set of figures. Each close task is a build step with inputs and outputs:

  • Settlement takes orders full of cost and outputs cost centers, products and assets that carry it.
  • Allocation takes cost centers and outputs other cost centers, or margin, that carry a share.
  • Accruals take purchase orders, contracts and history and output cost that belongs to the period.
  • Reconciliation takes two sets of postings that should agree and outputs a match, a reason or an adjustment.
  • Reporting takes all of it and outputs margin, statements and commentary.

A build fails when an input is missing, and it fails late when the missing input sits early in the chain. That is why a cost center missing from one equipment record in maintenance can move the date the group reports. Every worklist and AI idea in this topic does one of three things: finds a missing input before the step that needs it, matches outputs that should agree, or explains an output that moved.

Business objects and documents

Before you start: this topic builds on How an SAP enterprise is wired (company codes, controlling area), Document flow and how logistics becomes finance (the Universal Journal, GR/IR, account determination), and the process topics order to cash, procure to pay, plan to produce, warehouse and transportation and maintain to settle. Their open items are the inputs to this close. It doesn't repeat them.

The journal entry and the period

Every posting is a journal entry: a header with company code, fiscal year, document type, posting date and period, and items with G/L account, amount and the account assignments that controlling needs, such as cost center, order, profit center or profitability segment. In S/4HANA, the items sit in the Universal Journal; SAP's released CDS views are I_JournalEntry and I_JournalEntryItem, and the Cloudification Repository marks the tables underneath, ACDOCA and BKPF, as not to be released (Cloudification Repository).

Two fields on each item matter most for the close:

  • The posting period. The close is the moment a period stops taking postings. Companies usually open the next period before the month ends and keep the old one open for finance only for a few days.
  • The trading partner (PartnerCompany in the API model). It names the group company on the other side of an intercompany posting. Without it, the posting looks like business with a stranger (SAP Cloud SDK model, 2022).

The close task list

A close plan is a task list built from a template. SAP's training describes both tools this way:

  • In the Financial Closing cockpit in S/4HANA, the template follows a closing hierarchy of organizational units, such as controlling areas and their company codes. A periodic task list is created for a key date, fiscal year and period, and the system fills in time-related program parameters. Tasks can be notes, transactions, programs, jobs or workflows. Predecessor relationships on the Dependencies tab make the system run tasks in the right order (SAP Learning).
  • In SAP Advanced Financial Closing, templates "depict the closing structure" of the organization with folders, task list models and dependencies. Tasks can come from models, from spreadsheets, from external jobs or from workflows, and tasks with approval types need a responsible user or group, which a consistency check enforces (SAP Learning).

Both describe the same idea: a close is a directed graph of tasks, each with an owner, a day and predecessors. The script in this topic builds that graph for Calder.

Accruals

S/4HANA's Accruals Management uses an Accrual Engine with two parts that matter here (SAP Learning):

  • Purchase order accruals. The engine computes planned costs, by default from the PO item's delivery schedule, and actual costs already posted by goods receipts or invoices up to the key date. The accrual is planned minus actual, kept only if positive. A proposal run creates the amounts, usually after the last receipts and invoices of the period; a posting run posts them. Review by the PO owner or a cost accountant is optional, and if nobody reviews, the proposed amount posts. Approval is stricter: the amount posts only after approval. Postings carry reference procedure ACES4. The apps include Schedule Accruals Jobs and Display Purchase Order Accruals.
  • Manual accruals. Each recurring accrual, such as an annual fee, is an accrual object with an item type, a validity period, a total and an accrual method that spreads it over periods. At period end the posting run checks what the Universal Journal already holds and posts only the remaining amount (SAP Learning).

At Calder (fictional), the crane inspection on PO 4500000007 was done in October and its service entry sheet waits for approval, so October shows no cost for it. The PO accrual run proposes 3,800.00 USD to cost center CP11-410. Two manual accrual objects post too: the audit fee at 3,000.00 USD a month, and an estimate of October's Rockford utilities, 8,400.00 USD, based on the last three bills.

GR/IR review

The GR/IR clearing account holds two kinds of open items: goods received but not invoiced (a credit) and invoiced but not received (a debit). Items clear themselves when quantities and amounts match; the rest need clarification. SAP's apps for this are Reconcile GR/IR Accounts (F3302), Monitor GR/IR Account Reconciliation (F3303) and GR/IR Process Insights (F5796). Processors set a status, a priority and a root cause such as "Missing Goods Receipt", and only write-off statuses allow a write-off (SAP Learning).

At Calder, the review finds the two items from the procure-to-pay topic: 4,525.00 USD credit for five motors received in September and never invoiced, and 1,340.00 USD debit for four impellers invoiced by CP20 and not received.

Settlement and allocation

Production and maintenance orders settle at period end (plan to produce and maintain to settle cover how). Then allocations share out cost centers that serve others. SAP's training on S/4HANA's universal allocation describes two cycle types: distribution keeps the original primary cost elements on the receiver, and overhead allocation (assessment) posts with an assessment cost element and can also send to profit centers and profitability segments. Cycles are built in the Manage Allocations app and run at period end (SAP Learning).

At Calder, cycle CP11-OH (a course label) sends the utilities cost center CP11-460 to the assembly and test cost centers by October energy use. The compressor's September repair, order 4000112, should settle to CP11-460, but the compressor has no cost center. Run the cycle before that is fixed, and the repair cost misses October's product costs.

Foreign currency valuation

At period end, open items and balances in foreign currency are revalued at the closing rate with report FAGL_FCV (Perform Foreign Currency Valuation). The difference posts to an adjustment account, not the reconciliation account, and by default is reversed on the first day of the next period (SAP Learning). For Calder, CP20 keeps its books in EUR but bills CP10 in USD, so its USD receivables from CP10 are revalued every month.

Intercompany: how sales and purchasing create it

Intercompany balances are created by ordinary documents. When a plant of one company delivers for a sales order of another, SAP creates an internal invoice (billing type IV) in the delivering company code, priced with condition IV01, which references the statistical intercompany price PI01 on the customer's invoice (SAP Learning). When one company buys from another on a purchase order, as CP10 buys impellers from CP20, the seller bills and the buyer posts a supplier invoice. Either way, each side posts with the other as trading partner, and the two must agree.

Intercompany Matching and Reconciliation (ICMR) compares them. A matching method is a set of rules; each rule splits the data into two sides and applies a matching expression, and items that don't match move on to the next rule. Rules can express exact, suggested or exceptional matches. Reconciliation cases group results with tolerances that set the status. Items with differences get reason codes, and adjustments can post automatically or through an approval workflow (SAP Learning).

Margin analysis

Margin analysis records revenue and cost by profitability segment: the combination of product, customer, sales organization and other characteristics that a sale or cost belongs to. In S/4HANA it is stored in the Universal Journal; costing-based profitability analysis keeps value fields in its own tables and is not available in cloud deployments (SAP Learning). I_ProfitabilitySegment is a released CDS view (Cloudification Repository).

One timing fact explains many margin swings: the goods issue posts cost of goods sold when the goods leave, and billing posts revenue when the invoice is created. A delivery with a billing block at month end puts cost in one month and revenue in the next.

Group reporting

SAP's training on group reporting lists the steps: release the Universal Journal from integrated S/4HANA companies into the consolidation journal (from ACDOCA to ACDOCU), collect external data for companies on other systems by upload, manual entry or Group Reporting Data Collection, validate, translate currency, match intercompany items with ICMR, and eliminate intercompany balances (SAP Learning). Group journal entries change only the consolidation journal; I_CnsldtnGroupJrnlEntryTP is released (Cloudification Repository).

Document flow and postings

flowchart LR
  PO[PO 4500000007<br/>crane inspection] --> AC[Accrual<br/>3,800.00]
  MO[Order 4000112<br/>compressor] -.->|blocked| ST[Settlement]
  ST -.-> CC[CP11-460<br/>utilities]
  UT[Utilities<br/>accrual] --> CC
  CC --> AL[Cycle CP11-OH]
  AL --> PC[CP11-410,<br/>CP11-420]
  GI[Goods issue<br/>order 1000006] --> MA[Margin<br/>ES-200]
  IC[CP20 invoice<br/>95000047] -.->|in transit| R[ICMR<br/>timing]
Step Posts to finance Why
Purchase order accrual Cost (cost center or order) debited, accrued liability credited The service was done; the invoice hasn't posted
Manual accrual Cost debited, accrual account credited The cost belongs to the period
Reversal or clearing of an accrual The opposite, when the real cost arrives So the cost isn't counted twice
Order settlement Order credited; cost center, product or asset debited Costs reach their owner
Allocation Sender credited, receivers debited (assessment or original cost elements) Shared costs reach their users
Foreign currency valuation Adjustment account against exchange gain or loss Balances at the closing rate
GR/IR write-off (rare) GR/IR against an expense or price difference account Clearing an item that will never match
ICMR adjustment (with approval) Correction in one company A real difference, fixed at the source
Group elimination Consolidation journal only The group can't sell to itself

At Calder (fictional), the October accruals post 15,200.00 USD: 3,800.00 for the crane inspection, 3,000.00 for the audit fee and 8,400.00 for utilities. The allocation cycle sends 9,000.00 USD from CP11-460, which becomes 10,240.00 once order 4000112 can settle there. You compute both in Steps 5 and 6.

Neighbors

Neighbor Gives record to report Takes from record to report When the link fails
Order to cash Billing documents, revenue, receivables, disputes Credit exposure, dunning, revenue reports Delivered-not-billed orders shift margin between months
Procure to pay Goods receipts, invoices, service entry sheets Accruals, GR/IR clarification, payment run Services done but not approved leave costs out
Plan to produce Confirmations, WIP, production variances Settlement, product cost Unexplained variances land in margin
Warehouse and transport Freight settlement documents, carrier invoices Freight cost in margin Overbilled or duplicate freight inflates cost
Maintain to settle Maintenance order costs, capital work Settlement to cost centers and assets Costs stuck on orders; allocations miss them
Master data Cost centers, profit centers, trading partners, settlement rules Requests for missing fields Postings can't settle or can't be matched
Asset accounting Depreciation, capitalized work Asset balances Depreciation not run; capital work expensed
Other group companies Intercompany invoices, reporting packages Confirmations, eliminations Unmatched balances at group close
Data warehouse and planning Plans, targets, non-SAP data Actuals Reports disagree with the ledger

Where it breaks

Symptom Usual cause Who resolves it What it costs Calder (scenario)
Maintenance settlement blocked on WD2 Equipment 10000104 has no cost center, so order 4000112 has no receiver Master data, cost accountant Local close moves from WD3 to WD4
Allocation run planned before settlement Close calendar ignores the dependency Cost accountant 1,240.00 USD misses October's product costs
October maintenance cost looks low Crane inspection done, service entry sheet not approved Requester, G/L accountant 3,800.00 USD unbooked without the PO accrual
GR/IR shows 4,525.00 USD credit, 43 days old at month end Supplier never invoiced five motors Accounts payable Liability without an invoice; audit question
CP20 shows 13,400.00 USD more than CP10 Impellers shipped 30 October, still in transit Intercompany accountants Timing difference to record at group close
CP20's IT recharge doesn't match Booked without a trading partner Augsburg finance 2,500.00 USD left out of matching until reposted
ES-200 margin down 4.9 points One pump delivered with billing block AC: cost in October, revenue later Billing clerk, controller Margin understated for October, overstated next month
ES-300 margin down 14.1 points Production order 1100016 settled a 4,280.00 USD scrap variance Controller, production engineer Needs a documented explanation before sign-off
Freight cost higher than agreed Carrier invoice LFL-77120 at 22% fuel; LFL-77203 a duplicate Freight settlement clerk Overpayment if posted as billed

How it is exposed

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

Step API Released CDS views What to read
Journal entries API_JOURNALENTRYITEMBASIC_SRV (A_JournalEntryItemBasic, read) I_JournalEntry, I_JournalEntryItem, I_GLAccountLineItem, I_ActualPlanJournalEntryItem Company code, period, account, amounts, cost center, order, PartnerCompany, SoldMaterial
Posting journal entries Journal Entry - Post (SOAP, synchronous and asynchronous) I_JournalEntryTP (behavior definition) Only for designs where a person approves the entry
Open items I_OperationalAcctgDocItem Customer, supplier and G/L open items
Org and master data I_CompanyCode, I_Ledger, I_FiscalYearPeriodForVariant, I_GLAccountInCompanyCode, I_CostCenter, I_ProfitCenter, I_PartnerCompany Periods, accounts, trading partners
Manual accruals I_MnlAccrsAccrualSubobject Accrual objects and their parts
Margin I_ProfitabilitySegment Product, customer and other characteristics
Group reporting I_CnsldtnGroupJrnlEntryTP and many I_Cnsldtn... master data views Group journal entries, consolidation master data

An SAP Knowledge Base article notes that the accounting document read API covers only the entry view, and points to A_JournalEntryItemBasic for journal entry items (SAP KBA 2821106). The journal entry posting APIs are SOAP services under communication scenario SAP_COM_0002; SAP's guideline post lists what they don't do, such as clearing open items or calculating tax (SAP Community, 2019).

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 plays Calder's October close on the morning of workday 2. It builds the close as a graph of tasks, pushes the upstream issues from the earlier topics through it to find the projected close day and the critical path, proposes accruals, runs the utilities allocation with and without the stuck maintenance order, matches CP10 against CP20, and explains the margin swings with a draft commentary computed from the numbers. An optional step reads journal entry items from SAP's sandbox.

flowchart LR
  P[calder_pack/<br/>company codes,<br/>partners, products] --> S[record_to_report.py]
  SC[Scenario in the script<br/>tasks, issues, accruals,<br/>intercompany, margin] --> S
  S --> B[Close board]
  S --> A[Accruals<br/>--accruals]
  S --> AL[Allocation<br/>--allocation]
  S --> I[Matching<br/>--intercompany]
  S --> M[Margin and<br/>commentary --margin]
  S --> R[calder_close_report.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 40 minutes.

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

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

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

      source .venv/bin/activate

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

Step 2: Make sure the sample pack is current

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

    • Windows (PowerShell):

      python unit1b\make_calder_pack.py
    • macOS / Linux:

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

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

The close script checks that company codes CP10 (S/4HANA) and CP20 (ECC) and the intercompany partners 10000010 and 20000010 are in the pack, and it takes the pumps' standard costs from products.json.

Step 3: Create the script

  1. In VS Code, create a new file, paste the script below, and save it as record_to_report.py inside the unit1b folder.
  2. Read the blocks TASKS, issues(), PO_ACCRUAL_ITEMS, MANUAL_ACCRUALS, GRIR_OPEN, CP10_ITEMS, CP20_ITEMS and MARGIN first. They are Calder's October close written as Python data. Every value is invented. The open items come from the earlier Unit 1B topics: order 4000112 from maintain to settle, PO 4500000007 and the GR/IR items from procure to pay, order 1000006 from order to cash, the carrier invoices from warehouse and transportation, and production orders 1100015 and 1100016 from plan to produce. Task IDs, workdays, cycle names, reason codes and late counts are the course's own, not SAP values.
"""Record to report at Calder: the October close as a dependency chain.

Calder Pumpworks is a fictional company. The pack (unit1b/calder_pack/) and the
scenario below (close tasks, accruals, intercompany items, margins, amounts)
are made up for the course. Field names follow SAP's released APIs where the
script mirrors one (API_JOURNALENTRYITEMBASIC_SRV, I_JournalEntryItem), but the
logic is a teaching sketch, not SAP's. Task IDs, workdays, cycle names, reason
codes and late counts are course labels, not SAP values.

Run it from your course folder:
  python unit1b/record_to_report.py                 the close board for CP10, October 2026, on 2026-11-03
  python unit1b/record_to_report.py --accruals      purchase order and manual accruals, and the GR/IR review
  python unit1b/record_to_report.py --allocation    the utilities allocation, with and without order 4000112
  python unit1b/record_to_report.py --intercompany  match CP10 against CP20 by trading partner
  python unit1b/record_to_report.py --margin        margin by pump model, October vs September, with drivers
  python unit1b/record_to_report.py --write         write unit1b/calder_close_report.md
  python unit1b/record_to_report.py --sandbox       sum journal entry items 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 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"
PERIOD = "2026/010"          # fiscal year 2026, period 10 (October)
AS_OF_WD = 2                 # Tuesday 2026-11-03 is workday 2 of the close
WORKDAYS = {-1: "2026-10-30", 1: "2026-11-02", 2: "2026-11-03", 3: "2026-11-04",
            4: "2026-11-05", 5: "2026-11-06"}
LOCAL_CLOSE_TARGET = 3       # CP10's books for October are closed on workday 3
GROUP_CLOSE_TARGET = 5       # group figures are final on workday 5
MATCH_TOLERANCE = 10.00      # USD; intercompany differences up to this are accepted

# ------------------------------------------------------------ the master data fix
# Air compressor 10000104 has no cost center (see the maintain-to-settle topic), so
# maintenance order 4000112 can't settle. The exercise sets this to "CP11-460".
COMPRESSOR_COST_CENTER = ""

# ------------------------------------------------------------ close task list (fictional)
# (id, task, owner, planned workday, predecessors, status)
TASKS = [
    ("C01", "Open November; restrict October to finance", "G/L accountant", -1, [], "done"),
    ("C02", "Logistics cut-off: last October goods movements", "Warehouse and purchasing", -1, [], "done"),
    ("C03", "Billing due list for October deliveries", "Billing clerk", 1, ["C02"], "done"),
    ("C04", "Freight settlement and carrier invoices", "Freight settlement clerk", 1, ["C02"], "done"),
    ("C05", "GR/IR review", "Accounts payable", 1, ["C02"], "done"),
    ("C06", "Purchase order accruals: propose, review, post", "G/L accountant", 2, ["C05"], "in process"),
    ("C07", "Manual accruals: audit fee, utilities", "G/L accountant", 2, ["C01"], "open"),
    ("C08", "Production orders: WIP and variances", "Cost accountant", 2, ["C02"], "done"),
    ("C09", "Production order settlement", "Cost accountant", 2, ["C08"], "open"),
    ("C10", "Maintenance order settlement", "Cost accountant", 2, ["C02"], "open"),
    ("C11", "Overhead allocation cycle CP11-OH", "Cost accountant", 2, ["C07", "C10"], "open"),
    ("C12", "Foreign currency valuation", "G/L accountant", 2, ["C05"], "open"),
    ("X01", "CP20 (ECC) local close and reporting package", "Augsburg finance", 3, [], "open"),
    ("C13", "Intercompany reconciliation CP10-CP20", "Intercompany accountant", 3, ["C03", "C06", "X01"], "open"),
    ("C14", "Margin analysis review and commentary", "Controller", 3, ["C03", "C04", "C09", "C11"], "open"),
    ("C15", "Balance sheet reconciliations and sign-off", "Accounting manager", 3, ["C06", "C07", "C12", "C13"], "open"),
    ("C16", "Close October for all postings", "G/L accountant", 3, ["C14", "C15"], "open"),
    ("G01", "Group reporting: release, upload CP20, translate, eliminate", "Group accountant", 4, ["C16", "X01"], "open"),
]
# How often each task finished late in the last twelve closes (made up).
LATE_HISTORY = {"C06": 3, "C09": 2, "C10": 4, "C11": 3, "C13": 7, "C14": 5, "C15": 2, "X01": 4}

# ------------------------------------------------------------ open issues from upstream
# (id, task it hits, source process, what, owner, workday it is expected to be fixed, blocks?)
def issues() -> list:
    found = []
    if not COMPRESSOR_COST_CENTER:
        found.append(("I1", "C10", "maintain to settle",
                      "order 4000112 (1,240.00 USD) can't settle: equipment 10000104 has no cost center",
                      "Master data, with the cost accountant", 4, True))
    found += [
        ("I2", "C06", "procure to pay",
         "service entry sheet 1000000001 (crane inspection) not approved; the accrual proposal awaits review",
         "Requester (PO owner)", 2, False),
        ("I3", "C14", "order to cash",
         "order 1000006 delivered on 2026-10-20 but billing block AC: cost in October, revenue later",
         "Billing clerk, with sales", 4, False),
        ("I4", "C04", "warehouse and transport",
         "carrier invoice LFL-77203 duplicates LFL-77188; LFL-77120 billed fuel at 22%",
         "Freight settlement clerk", 3, False),
    ]
    if not intercompany_partner_fixed():
        found.append(("I5", "C13", "intercompany (CP20 on ECC)",
                      "CP20 booked CP10's IT service recharge without a trading partner",
                      "Augsburg finance", 3, True))
    return found


# ------------------------------------------------------------ accruals (fictional)
# Purchase order accruals: planned cost from the delivery schedule up to the key date,
# minus actual cost posted (goods receipts, approved service entry sheets, invoices).
PO_ACCRUAL_ITEMS = [
    # (PO, item text, supplier, account assignment, planned by 2026-10-31, actual posted, reviewer)
    ("4500000007", "Annual inspection, overhead crane", "20000004 Corvane Lifting Services",
     "cost center CP11-410", 3800.00, 0.00, "PO owner (requester)"),
    ("4500000002", "20 x CP-MTR15 motors, due 2026-11-05", "20000001 Orrin Motor Works",
     "stock (GR-based)", 0.00, 0.00, "none needed"),
    ("4500000006", "5 x CP-MTR15 motors, received 2026-09-18", "20000001 Orrin Motor Works",
     "stock (GR-based)", 4525.00, 4525.00, "none needed"),
]
# Manual accruals: accrual objects with a total, a method and what is already posted.
MANUAL_ACCRUALS = [
    # (accrual object, text, cost center, total for the year, method, posted through September)
    ("ACC-AUD-2026", "Annual audit fee 2026", "CP10-900", 36000.00, "straight line Jan-Dec", 27000.00),
    ("ACC-UTL-2610", "Rockford electricity and gas, October (bill due mid-November)", "CP11-460",
     8400.00, "one period, estimate", 0.00),
]
UTILITY_BILLS = {"2026-07": 8150.00, "2026-08": 8620.00, "2026-09": 8430.00}  # basis of the estimate
# GR/IR open items after the procure-to-pay topic (USD; credit negative).
GRIR_OPEN = [
    ("4500000006", "20000001 Orrin Motor Works", "received 2026-09-18, not invoiced", -4525.00),
    ("4500000005", "20000010 Calder GmbH (intercompany)", "40 invoiced, 36 received", 1340.00),
]

# ------------------------------------------------------------ allocation (fictional)
# Cycle CP11-OH sends the utilities cost center CP11-460 to the production cost centers
# by October kWh (course values). Order 4000112 would settle to CP11-460 once the
# compressor has that cost center.
ALLOC_SENDER = "CP11-460"
ALLOC_RECEIVERS = {"CP11-410 (ASSY1)": 0.60, "CP11-420 (TEST1)": 0.40}
SENDER_COSTS = [("depreciation, compressor asset 30000021", 600.00)]  # posted in October

# ------------------------------------------------------------ intercompany items (fictional)
# CP10 runs S/4HANA; CP20 runs ECC and reports through an upload. Amounts in transaction
# currency USD (the currency of the intercompany invoices), so exchange rates don't
# create differences at this level. PartnerCompany is the trading partner.
CP10_ITEMS = [
    {"CompanyCode": "CP10", "PartnerCompany": "CP20", "Account": "payable to 20000010",
     "Reference": "95000041", "Document": "5105600004", "AmountInTransactionCurrency": -13400.00,
     "Text": "40 x CP-IMP200 for PO 4500000005"},
    {"CompanyCode": "CP10", "PartnerCompany": "CP20", "Account": "receivable from CP20",
     "Reference": "90000051", "Document": "90000051", "AmountInTransactionCurrency": 2500.00,
     "Text": "IT service recharge October"},
]
CP20_ITEMS = [
    {"CompanyCode": "CP20", "PartnerCompany": "CP10", "Account": "receivable from 10000010",
     "Reference": "95000041", "Document": "95000041", "AmountInTransactionCurrency": 13400.00,
     "Text": "40 x CP-IMP200, shipped 2026-10-07"},
    {"CompanyCode": "CP20", "PartnerCompany": "CP10", "Account": "receivable from 10000010",
     "Reference": "95000047", "Document": "95000047", "AmountInTransactionCurrency": 13400.00,
     "Text": "40 x CP-IMP200 for PO 4500000008, shipped 2026-10-30"},
    {"CompanyCode": "CP20", "PartnerCompany": "", "Account": "payable to an IT supplier account",
     "Reference": "90000051", "Document": "1900000388", "AmountInTransactionCurrency": -2500.00,
     "Text": "IT service Calder Inc. October"},
]


def intercompany_partner_fixed() -> bool:
    return all(i["PartnerCompany"] for i in CP20_ITEMS)


# ------------------------------------------------------------ margin (fictional)
# Margin by product in CP10: billed revenue, cost of goods sold at standard from goods
# issues, production variances settled in the month, and freight. Units and amounts are
# course values; prices and standard costs come from the sample pack.
MARGIN = {
    "2026-09": {"CP-ES200": {"billed": 14, "issued": 14, "variance": 0.00, "freight": 1800.00},
                "CP-ES300": {"billed": 4, "issued": 4, "variance": 0.00, "freight": 1000.00}},
    "2026-10": {"CP-ES200": {"billed": 16, "issued": 17, "variance": 388.00, "freight": 2100.00},
                "CP-ES300": {"billed": 5, "issued": 5, "variance": 4280.00, "freight": 1320.00}},
}
PRICE = {"CP-ES200": 4200.00, "CP-ES300": 6150.00}   # net price per pump, as in order 1000001
EVIDENCE = {
    ("CP-ES200", "issued"): "order 1000006: 1 pump delivered 2026-10-20, billing block AC",
    ("CP-ES200", "variance"): "production order 1100015 settled, 388.00 USD to price differences",
    ("CP-ES300", "variance"): "production order 1100016 settled, 1 pump scrapped at the pressure test",
}


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


def wd(n: int) -> str:
    return f"WD{n:+d}" if n < 0 else f"WD{n}"


def check_pack() -> dict:
    try:
        org = json.loads((PACK / "org_units.json").read_text(encoding="utf-8"))
        partners = json.loads((PACK / "business_partners.json").read_text(encoding="utf-8"))
        products = json.loads((PACK / "products.json").read_text(encoding="utf-8"))
    except FileNotFoundError:
        sys.exit(f"Cannot read {PACK}. Run: python unit1b/make_calder_pack.py (Unit 1B setup).")
    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.")
    ids = {bp["BusinessPartner"] for bp in partners["BusinessPartners"]}
    if not {"10000010", "20000010"} <= ids:
        sys.exit("Intercompany partners 10000010 and 20000010 are missing from the pack. Run make_calder_pack.py again.")
    return {p["Product"]: p["to_Valuation"][0]["StandardPrice"] for p in products["Products"]}


# --------------------------------------------------------------- the close board
def schedule() -> dict:
    """Projected finish workday for each task: its planned day, pushed by open blocking
    issues and by its predecessors. Tasks are listed so predecessors come first."""
    blocking = {}
    for iid, task, _src, _what, _owner, fix_wd, blocks in issues():
        if blocks and fix_wd > blocking.get(task, (0, ""))[0]:
            blocking[task] = (fix_wd, iid)
    plan = {}
    for tid, _name, _owner, planned, preds, status in TASKS:
        if status == "done":
            plan[tid] = {"finish": planned, "driver": ""}
            continue
        finish, driver = planned, ""
        for p in preds:
            if plan[p]["finish"] > finish:
                finish, driver = plan[p]["finish"], plan[p]["driver"]
        fix_wd, iid = blocking.get(tid, (0, ""))
        if fix_wd > finish:
            finish, driver = fix_wd, iid
        plan[tid] = {"finish": finish, "driver": driver}
    return plan


def critical_path(plan: dict) -> list:
    """Walk back from the group close along the predecessor that finishes last
    (on a tie, the one with the longer chain of tasks behind it)."""
    preds = {t[0]: t[4] for t in TASKS}
    depth = {}
    for tid, *_rest in TASKS:  # predecessors come first in TASKS
        depth[tid] = 1 + max((depth[p] for p in preds[tid]), default=0)
    path, node = [], "G01"
    while node:
        path.append(node)
        node = max(preds[node], key=lambda p: (plan[p]["finish"], depth[p]), default=None)
    return list(reversed(path))


def risk(tid: str, plan: dict) -> str:
    planned = next(t[3] for t in TASKS if t[0] == tid)
    status = next(t[5] for t in TASKS if t[0] == tid)
    if status == "done":
        return "-"
    late_rate = LATE_HISTORY.get(tid, 0) / 12
    if plan[tid]["finish"] > planned:
        return "LATE"
    return "high" if late_rate >= 0.4 else "medium" if late_rate >= 0.2 else "low"


def print_board() -> None:
    plan = schedule()
    path = set(critical_path(plan))
    print(f"Close board, company code CP10, period {PERIOD}, as of {wd(AS_OF_WD)} {WORKDAYS[AS_OF_WD]}"
          " (fictional Calder data)\n")
    print(f"  {'Task':<5}{'':<2}{'Plan':<5}{'Proj':<5}{'Status':<11}{'Risk':<7}What")
    for tid, name, owner, planned, _preds, status in TASKS:
        mark = "*" if tid in path else " "
        print(f"  {tid:<5}{mark:<2}{wd(planned):<5}{wd(plan[tid]['finish']):<5}{status:<11}{risk(tid, plan):<7}{name}")
        if plan[tid]["finish"] > planned:
            print(f"  {'':<30}late because of issue {plan[tid]['driver']}; task owner: {owner}")
    print("\n  * critical path: " + " -> ".join(critical_path(plan)))
    local, group = plan["C16"]["finish"], plan["G01"]["finish"]
    print(f"  CP10 local close: target {wd(LOCAL_CLOSE_TARGET)}, projected {wd(local)}")
    print(f"  Group reporting:  target {wd(GROUP_CLOSE_TARGET)}, projected {wd(group)}")
    print("\n  Open issues from upstream processes:")
    for iid, task, src, what, owner, fix, blocks in issues():
        kind = "BLOCKS" if blocks else "risk"
        print(f"   {iid} {kind:<7}{task}  ({src}) {what}")
        print(f"   {'':<10}owner: {owner}; expected fix {wd(fix)}")
    print("\n  Risk: LATE = projected after plan; otherwise from the task's late rate in the last 12 closes.")
    print("  Synthetic data: the board shows the method, not results from an SAP system.")


# --------------------------------------------------------------- accruals
def print_accruals() -> None:
    print(f"Accruals for CP10, period {PERIOD}, key date 2026-10-31 (fictional)\n")
    print("  Purchase order accruals: planned cost to the key date minus actual cost posted")
    total = 0.0
    for po, text, supplier, assign, planned, actual, reviewer in PO_ACCRUAL_ITEMS:
        proposal = max(planned - actual, 0.0)
        total += proposal
        print(f"    PO {po}  {text}")
        print(f"      {supplier}; {assign}; planned {money(planned)}, actual {money(actual)}, "
              f"proposal {money(proposal)}; review: {reviewer}")
    print(f"    proposed total {money(total)} USD. If nobody reviews it, the proposal posts as it is.\n")
    print("  Manual accruals: accrual objects")
    for obj, text, cc, year_total, method, posted in MANUAL_ACCRUALS:
        if method.startswith("straight"):
            due = round(year_total * 10 / 12, 2)
        else:
            due = year_total
        print(f"    {obj}  {text}")
        print(f"      cost center {cc}; {method}; total {money(year_total)}, posted {money(posted)}, "
              f"October posting {money(due - posted)}")
    avg = sum(UTILITY_BILLS.values()) / len(UTILITY_BILLS)
    print(f"    utilities estimate: average of the last three bills {money(avg)}, rounded to 8,400.00")
    print("\n  GR/IR open items at period end (debit +, credit -)")
    for po, supplier, why, amount in GRIR_OPEN:
        print(f"    PO {po}  {supplier:<38}{why:<36}{money(amount):>10}")
    net = sum(a for *_, a in GRIR_OPEN)
    print(f"    net GR/IR balance {money(net)}: a liability without an invoice, and an invoice without goods")
    print("\n  Synthetic numbers for a fictional company, not from an SAP system.")


# --------------------------------------------------------------- allocation
def print_allocation() -> None:
    utilities = next(a for a in MANUAL_ACCRUALS if a[0] == "ACC-UTL-2610")[3]
    lines = list(SENDER_COSTS) + [("utilities accrual ACC-UTL-2610", utilities)]
    if COMPRESSOR_COST_CENTER == ALLOC_SENDER:
        lines.append(("maintenance order 4000112 settled", 1240.00))
    print(f"Allocation cycle CP11-OH for period {PERIOD}: sender {ALLOC_SENDER} (fictional)\n")
    for text, amount in lines:
        print(f"  {text:<44}{money(amount):>10}")
    total = round(sum(a for _, a in lines), 2)
    print(f"  {'sender total':<44}{money(total):>10}\n")
    for receiver, share in ALLOC_RECEIVERS.items():
        print(f"  to {receiver:<41}{money(total * share):>10}  ({share:.0%} of October kWh)")
    if COMPRESSOR_COST_CENTER != ALLOC_SENDER:
        print("\n  Order 4000112's 1,240.00 USD is not in the cycle: the order can't settle yet.")
        print("  Run the cycle now and that cost stays behind; run it after task C10 and it reaches")
        print("  the production cost centers, and through their activity rates, the pumps.")
    print("\n  Synthetic numbers for a fictional company, not from an SAP system.")


# --------------------------------------------------------------- intercompany
def match_intercompany() -> list:
    """Three rules, applied in order, like a matching method: exact, suggested, exception."""
    left = [dict(i) for i in CP10_ITEMS]
    right = [dict(i) for i in CP20_ITEMS]
    results = []
    # Rule 1: partner pair, same reference, amounts cancel within the tolerance
    for a in left:
        for b in right:
            if b.get("used") or a.get("used"):
                continue
            pair = a["PartnerCompany"] == b["CompanyCode"] and b["PartnerCompany"] == a["CompanyCode"]
            diff = round(a["AmountInTransactionCurrency"] + b["AmountInTransactionCurrency"], 2)
            if pair and a["Reference"] == b["Reference"] and abs(diff) <= MATCH_TOLERANCE:
                a["used"] = b["used"] = True
                results.append(("matched (rule 1: partner, reference, amount)", a, b, diff, ""))
    # Rule 3 before leaving items open: same reference and opposite amount, but a partner missing
    for a in left:
        for b in right:
            if a.get("used") or b.get("used"):
                continue
            diff = round(a["AmountInTransactionCurrency"] + b["AmountInTransactionCurrency"], 2)
            if a["Reference"] == b["Reference"] and abs(diff) <= MATCH_TOLERANCE and not (
                    a["PartnerCompany"] and b["PartnerCompany"]):
                a["used"] = b["used"] = True
                results.append(("exception (rule 3: trading partner missing)", a, b, diff, "MISSING_TP"))
    for a in left:
        if not a.get("used"):
            results.append(("open on CP10 only", a, None, a["AmountInTransactionCurrency"], "CHECK"))
    for b in right:
        if not b.get("used"):
            reason = "TIMING" if "shipped" in b["Text"] else "CHECK"
            results.append(("open on CP20 only", None, b, b["AmountInTransactionCurrency"], reason))
    return results


def print_intercompany() -> None:
    print(f"Intercompany reconciliation CP10 <-> CP20, period {PERIOD}, transaction currency USD (fictional)\n")
    open_total = 0.0
    for label, a, b, diff, reason in match_intercompany():
        print(f"  {label}")
        for side in (a, b):
            if side:
                tp = side["PartnerCompany"] or "(none)"
                print(f"    {side['CompanyCode']} {side['Document']:<11}ref {side['Reference']:<9}partner {tp:<7}"
                      f"{money(side['AmountInTransactionCurrency']):>11}  {side['Text']}")
        if reason:
            print(f"    difference {money(diff)}; reason code {reason}")
            open_total += abs(diff) if reason != "MISSING_TP" else 0
        print()
    print(f"  Unexplained or open: {money(open_total)} USD. Reason codes are course labels.")
    print("  CP20 runs ECC: its items arrive with the reporting package, so they are only as")
    print("  current as the last upload.")
    print("\n  Synthetic numbers for a fictional company, not from an SAP system.")


# --------------------------------------------------------------- margin
def margin_rows(std: dict) -> dict:
    rows = {}
    for month, products in MARGIN.items():
        for product, m in products.items():
            revenue = m["billed"] * PRICE[product]
            cogs = m["issued"] * std[product]
            margin = revenue - cogs - m["variance"] - m["freight"]
            rows[(month, product)] = {"revenue": revenue, "cogs": cogs, "variance": m["variance"],
                                      "freight": m["freight"], "margin": margin,
                                      "pct": margin / revenue if revenue else 0.0,
                                      "billed": m["billed"], "issued": m["issued"]}
    return rows


def drivers(product: str, std: dict, rows: dict) -> list:
    """Percentage-point effects on October's margin, each computed from the data."""
    oct_ = rows[("2026-10", product)]
    sep = rows[("2026-09", product)]
    out = []
    unbilled = oct_["issued"] - oct_["billed"]
    if unbilled:
        noun = "pump" if unbilled == 1 else "pumps"
        out.append((f"{unbilled} {noun} issued, not billed: cost without revenue",
                    -unbilled * std[product] / oct_["revenue"], EVIDENCE.get((product, "issued"), "")))
    if oct_["variance"]:
        out.append(("production variance settled", -oct_["variance"] / oct_["revenue"],
                    EVIDENCE.get((product, "variance"), "")))
    freight_change = oct_["freight"] / oct_["revenue"] - sep["freight"] / sep["revenue"]
    if abs(freight_change) >= 0.0005:
        out.append(("freight per revenue dollar", -freight_change, "freight orders settled in October"))
    explained = sum(e for _, e, _ in out)
    residual = (oct_["pct"] - sep["pct"]) - explained
    out.append(("not explained by the rules above", residual, ""))
    return out


def print_margin(std: dict) -> None:
    rows = margin_rows(std)
    print("Margin by pump model, company code CP10 (USD, fictional)\n")
    print(f"  {'Product':<10}{'Month':<9}{'Billed':>7}{'Revenue':>12}{'COGS':>12}{'Variance':>10}"
          f"{'Freight':>10}{'Margin':>11}{'%':>7}")
    for product in PRICE:
        for month in MARGIN:
            r = rows[(month, product)]
            print(f"  {product:<10}{month:<9}{r['billed']:>7}{money(r['revenue']):>12}{money(r['cogs']):>12}"
                  f"{money(r['variance']):>10}{money(r['freight']):>10}{money(r['margin']):>11}{r['pct']:>7.1%}")
    print()
    for product in PRICE:
        swing = rows[("2026-10", product)]["pct"] - rows[("2026-09", product)]["pct"]
        print(f"  {product}: margin {swing * 100:+.1f} points vs September")
        for text, effect, evidence in drivers(product, std, rows):
            print(f"    {effect * 100:+6.1f}  {text}" + (f"  [{evidence}]" if evidence else ""))
    print("\n  Draft commentary (built from the numbers; a controller reviews and signs it):")
    for line in commentary(std, rows):
        print(f"    {line}")
    print("\n  Synthetic numbers for a fictional company, not from an SAP system.")


def commentary(std: dict, rows: dict) -> list:
    lines = []
    for product in PRICE:
        o, s = rows[("2026-10", product)], rows[("2026-09", product)]
        main = max(drivers(product, std, rows)[:-1], key=lambda d: abs(d[1]))
        lines.append(f"{product} margin {o['pct']:.1%} in October vs {s['pct']:.1%} in September; "
                     f"largest driver: {main[0]} ({main[1] * 100:+.1f} pts, {main[2]}).")
    return lines


# --------------------------------------------------------------- report
def write_report(std: dict) -> Path:
    plan = schedule()
    out = HERE / "calder_close_report.md"
    lines = [f"# Close report, CP10, period {PERIOD}, {wd(AS_OF_WD)}", "",
             "Fictional Calder Pumpworks data. Synthetic: it shows the method, not results from an SAP system.", "",
             "```mermaid", "flowchart LR"]
    for tid, _name, _owner, _planned, preds, _status in TASKS:
        for p in preds:
            lines.append(f"  {p} --> {tid}")
    lines += ["```", "", "| Task | Name | Plan | Projected | Status | Risk |", "| --- | --- | --- | --- | --- | --- |"]
    for tid, name, _owner, planned, _preds, status in TASKS:
        lines.append(f"| {tid} | {name} | {wd(planned)} | {wd(plan[tid]['finish'])} | {status} | {risk(tid, plan)} |")
    lines += ["", "## Open issues", "", "| Issue | Task | Source | What | Owner | Fix |", "| --- | --- | --- | --- | --- | --- |"]
    for iid, task, src, what, owner, fix, _b in issues():
        lines.append(f"| {iid} | {task} | {src} | {what} | {owner} | {wd(fix)} |")
    lines += ["", "## Draft margin commentary", ""] + [f"- {c}" for c in commentary(std, margin_rows(std))]
    out.write_text("\n".join(lines) + "\n", encoding="utf-8")
    return out


# --------------------------------------------------------------- sandbox (optional)
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_JOURNALENTRYITEMBASIC_SRV/A_JournalEntryItemBasic")
    query = urllib.parse.urlencode({"$top": "100", "$format": "json",
                                    "$select": "CompanyCode,FiscalYearPeriod,GLAccount,GLAccountName,"
                                               "PartnerCompany,AmountInCompanyCodeCurrency,CompanyCodeCurrency"})
    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_account, partner_lines = {}, 0
    for it in items:
        acct = (it.get("GLAccount", ""), it.get("GLAccountName", ""), it.get("CompanyCodeCurrency", ""))
        by_account[acct] = by_account.get(acct, 0.0) + float(it.get("AmountInCompanyCodeCurrency") or 0)
        partner_lines += 1 if it.get("PartnerCompany") else 0
    print(f"SAP sandbox: {len(items)} journal entry items read (SAP demo data, not Calder's)\n")
    for (acct, name, cur), amount in sorted(by_account.items(), key=lambda x: -abs(x[1]))[:8]:
        print(f"  {acct:<12}{name[:30]:<32}{money(amount):>16} {cur}")
    print(f"\n  lines with a trading partner: {partner_lines} of {len(items)}")
    print("  The sample is the first 100 items, not a balance: don't read it as a trial balance.")


# --------------------------------------------------------------- main
def main() -> None:
    parser = argparse.ArgumentParser(description="Record to report at Calder (fictional data).")
    parser.add_argument("--accruals", action="store_true", help="purchase order and manual accruals, GR/IR review")
    parser.add_argument("--allocation", action="store_true", help="the utilities allocation cycle")
    parser.add_argument("--intercompany", action="store_true", help="match CP10 against CP20")
    parser.add_argument("--margin", action="store_true", help="margin by pump model with drivers")
    parser.add_argument("--write", action="store_true", help="write unit1b/calder_close_report.md")
    parser.add_argument("--sandbox", action="store_true", help="sum journal entry items from SAP's sandbox")
    args = parser.parse_args()
    if args.sandbox:
        sandbox()
        return
    std = check_pack()
    if args.accruals:
        print_accruals()
    elif args.allocation:
        print_allocation()
    elif args.intercompany:
        print_intercompany()
    elif args.margin:
        print_margin(std)
    else:
        print_board()
        if args.write:
            print(f"\nwrote unit1b/{write_report(std).name}")


if __name__ == "__main__":
    main()

Step 4: Read the close board

  1. Run the script without options:

    • Windows (PowerShell):

      python unit1b\record_to_report.py
    • macOS / Linux:

      python unit1b/record_to_report.py
  2. You should see exactly this. The script has no random parts, so your output must match:

Close board, company code CP10, period 2026/010, as of WD2 2026-11-03 (fictional Calder data)

  Task   Plan Proj Status     Risk   What
  C01    WD-1 WD-1 done       -      Open November; restrict October to finance
  C02  * WD-1 WD-1 done       -      Logistics cut-off: last October goods movements
  C03    WD1  WD1  done       -      Billing due list for October deliveries
  C04    WD1  WD1  done       -      Freight settlement and carrier invoices
  C05    WD1  WD1  done       -      GR/IR review
  C06    WD2  WD2  in process medium Purchase order accruals: propose, review, post
  C07    WD2  WD2  open       low    Manual accruals: audit fee, utilities
  C08    WD2  WD2  done       -      Production orders: WIP and variances
  C09    WD2  WD2  open       low    Production order settlement
  C10  * WD2  WD4  open       LATE   Maintenance order settlement
                                late because of issue I1; task owner: Cost accountant
  C11  * WD2  WD4  open       LATE   Overhead allocation cycle CP11-OH
                                late because of issue I1; task owner: Cost accountant
  C12    WD2  WD2  open       low    Foreign currency valuation
  X01    WD3  WD3  open       medium CP20 (ECC) local close and reporting package
  C13    WD3  WD3  open       high   Intercompany reconciliation CP10-CP20
  C14  * WD3  WD4  open       LATE   Margin analysis review and commentary
                                late because of issue I1; task owner: Controller
  C15    WD3  WD3  open       low    Balance sheet reconciliations and sign-off
  C16  * WD3  WD4  open       LATE   Close October for all postings
                                late because of issue I1; task owner: G/L accountant
  G01  * WD4  WD4  open       low    Group reporting: release, upload CP20, translate, eliminate

  * critical path: C02 -> C10 -> C11 -> C14 -> C16 -> G01
  CP10 local close: target WD3, projected WD4
  Group reporting:  target WD5, projected WD4

  Open issues from upstream processes:
   I1 BLOCKS C10  (maintain to settle) order 4000112 (1,240.00 USD) can't settle: equipment 10000104 has no cost center
             owner: Master data, with the cost accountant; expected fix WD4
   I2 risk   C06  (procure to pay) service entry sheet 1000000001 (crane inspection) not approved; the accrual proposal awaits review
             owner: Requester (PO owner); expected fix WD2
   I3 risk   C14  (order to cash) order 1000006 delivered on 2026-10-20 but billing block AC: cost in October, revenue later
             owner: Billing clerk, with sales; expected fix WD4
   I4 risk   C04  (warehouse and transport) carrier invoice LFL-77203 duplicates LFL-77188; LFL-77120 billed fuel at 22%
             owner: Freight settlement clerk; expected fix WD3
   I5 BLOCKS C13  (intercompany (CP20 on ECC)) CP20 booked CP10's IT service recharge without a trading partner
             owner: Augsburg finance; expected fix WD3

  Risk: LATE = projected after plan; otherwise from the task's late rate in the last 12 closes.
  Synthetic data: the board shows the method, not results from an SAP system.

How to read it:

  • Each task has a planned and a projected workday. The projection starts from the plan and is pushed later by a blocking issue on the task, or by a predecessor that finishes late.
  • Issue I1 moves the close. Order 4000112 can't settle until master data gives the compressor a cost center, expected on WD4. Settlement (C10) moves to WD4, so the allocation (C11) that needs its cost moves too, then the margin review (C14) that needs the allocation, then the close itself (C16). One field in a maintenance record decides CP10's close day.
  • Issue I5 blocks but doesn't delay. CP20 can repost its IT charge with a trading partner on WD3, the day the intercompany reconciliation is planned anyway.
  • The other issues are risks, not blockers. The accrual for the crane inspection posts as proposed if nobody reviews it (Step 5 explains why). The billing block on order 1000006 and the carrier invoices don't stop any task, but they change what the margin review has to explain.
  • The critical path walks back from group reporting along the predecessor that finishes last: C02 -> C10 -> C11 -> C14 -> C16 -> G01. That is where a controller spends Tuesday morning.
  • Risk for tasks that aren't late comes from how often each one finished late in the last twelve closes: 40% or more is high, 20% or more medium. Intercompany reconciliation was late seven times in twelve. This is the simplest possible risk model; Step 8 explains what a better one would need.

Step 5: Check the accruals and the GR/IR review

  1. Run:

    • Windows (PowerShell):

      python unit1b\record_to_report.py --accruals
    • macOS / Linux:

      python unit1b/record_to_report.py --accruals
  2. You should see:

Accruals for CP10, period 2026/010, key date 2026-10-31 (fictional)

  Purchase order accruals: planned cost to the key date minus actual cost posted
    PO 4500000007  Annual inspection, overhead crane
      20000004 Corvane Lifting Services; cost center CP11-410; planned 3,800.00, actual 0.00, proposal 3,800.00; review: PO owner (requester)
    PO 4500000002  20 x CP-MTR15 motors, due 2026-11-05
      20000001 Orrin Motor Works; stock (GR-based); planned 0.00, actual 0.00, proposal 0.00; review: none needed
    PO 4500000006  5 x CP-MTR15 motors, received 2026-09-18
      20000001 Orrin Motor Works; stock (GR-based); planned 4,525.00, actual 4,525.00, proposal 0.00; review: none needed
    proposed total 3,800.00 USD. If nobody reviews it, the proposal posts as it is.

  Manual accruals: accrual objects
    ACC-AUD-2026  Annual audit fee 2026
      cost center CP10-900; straight line Jan-Dec; total 36,000.00, posted 27,000.00, October posting 3,000.00
    ACC-UTL-2610  Rockford electricity and gas, October (bill due mid-November)
      cost center CP11-460; one period, estimate; total 8,400.00, posted 0.00, October posting 8,400.00
    utilities estimate: average of the last three bills 8,400.00, rounded to 8,400.00

  GR/IR open items at period end (debit +, credit -)
    PO 4500000006  20000001 Orrin Motor Works            received 2026-09-18, not invoiced    -4,525.00
    PO 4500000005  20000010 Calder GmbH (intercompany)   40 invoiced, 36 received              1,340.00
    net GR/IR balance -3,185.00: a liability without an invoice, and an invoice without goods

  Synthetic numbers for a fictional company, not from an SAP system.

How to read it:

  • Purchase order accruals follow SAP's rule: planned cost to the key date minus actual cost posted. The crane inspection was planned for 13 October and nothing has posted, so 3,800.00 USD is proposed. The September motors are fully received, so their GR/IR credit already carries the liability and no accrual is needed. The November motors aren't due yet.
  • Review is optional. In SAP's process, a proposal that nobody reviews posts as proposed; only an approval step holds it back. That is why issue I2 is a risk, not a blocker. It is also why the PO owner should look: if the inspection was cancelled, the accrual is wrong.
  • Manual accruals post what is due minus what is posted. The audit fee is straight-line over twelve months, so 3,000.00 USD falls in October. The utilities estimate is the average of the last three bills, which is exactly the kind of estimate an accruals agent would propose from history, with its basis shown.
  • GR/IR has two open items that point in opposite directions. The net balance, 3,185.00 USD credit, hides both: always review GR/IR by item, never by balance.

Step 6: Run the allocation with and without the stuck order

  1. Run:

    • Windows (PowerShell):

      python unit1b\record_to_report.py --allocation
    • macOS / Linux:

      python unit1b/record_to_report.py --allocation
  2. You should see:

Allocation cycle CP11-OH for period 2026/010: sender CP11-460 (fictional)

  depreciation, compressor asset 30000021         600.00
  utilities accrual ACC-UTL-2610                8,400.00
  sender total                                  9,000.00

  to CP11-410 (ASSY1)                           5,400.00  (60% of October kWh)
  to CP11-420 (TEST1)                           3,600.00  (40% of October kWh)

  Order 4000112's 1,240.00 USD is not in the cycle: the order can't settle yet.
  Run the cycle now and that cost stays behind; run it after task C10 and it reaches
  the production cost centers, and through their activity rates, the pumps.

  Synthetic numbers for a fictional company, not from an SAP system.

The cycle shares the utilities cost center by energy use: 60% to assembly, 40% to test. Without the compressor repair, it sends 9,000.00 USD. With it, it would send 10,240.00 USD. If a cost accountant runs the cycle on WD2 to stay on plan, the 1,240.00 USD stays on the maintenance order, then on the utilities cost center after a late settlement, and reaches the products a month late. Waiting for task C10 costs a day; not waiting costs accuracy. The exercise shows the numbers after the fix.

Step 7: Match CP10 against CP20

  1. Run:

    • Windows (PowerShell):

      python unit1b\record_to_report.py --intercompany
    • macOS / Linux:

      python unit1b/record_to_report.py --intercompany
  2. You should see:

Intercompany reconciliation CP10 <-> CP20, period 2026/010, transaction currency USD (fictional)

  matched (rule 1: partner, reference, amount)
    CP10 5105600004 ref 95000041 partner CP20    -13,400.00  40 x CP-IMP200 for PO 4500000005
    CP20 95000041   ref 95000041 partner CP10     13,400.00  40 x CP-IMP200, shipped 2026-10-07

  exception (rule 3: trading partner missing)
    CP10 90000051   ref 90000051 partner CP20      2,500.00  IT service recharge October
    CP20 1900000388 ref 90000051 partner (none)   -2,500.00  IT service Calder Inc. October
    difference 0.00; reason code MISSING_TP

  open on CP20 only
    CP20 95000047   ref 95000047 partner CP10     13,400.00  40 x CP-IMP200 for PO 4500000008, shipped 2026-10-30
    difference 13,400.00; reason code TIMING

  Unexplained or open: 13,400.00 USD. Reason codes are course labels.
  CP20 runs ECC: its items arrive with the reporting package, so they are only as
  current as the last upload.

  Synthetic numbers for a fictional company, not from an SAP system.

How to read it:

  • The script applies rules in order, like an ICMR matching method. Rule 1 needs the trading partner pair, the same reference and amounts that cancel within 10.00 USD. What doesn't match moves on.
  • The impeller invoice for PO 4500000005 matches, even though CP10 has only received 36 of the 40 impellers. Matching compares the two companies' books; the missing four are a GR/IR question inside CP10 (Step 5), not an intercompany difference.
  • The IT recharge matches by reference and amount, but CP20 has no trading partner on it. The script flags it as an exception rather than a match: until CP20 reposts it, group reporting can't eliminate it, because it looks like a payable to an outside supplier.
  • The second impeller invoice is on CP20's side only. It was shipped on 30 October and CP10 hasn't received goods or invoice. That is a timing difference: real, explainable and recorded with a reason code, not chased as an error.
  • Matching in transaction currency (USD, the invoice currency) keeps exchange rates out of the comparison. In group currency, CP20's EUR books translated at period-end rates would add small differences that need their own rule.

Step 8: Explain the margin swings and write the close report

  1. Run:

    • Windows (PowerShell):

      python unit1b\record_to_report.py --margin
    • macOS / Linux:

      python unit1b/record_to_report.py --margin
  2. You should see:

Margin by pump model, company code CP10 (USD, fictional)

  Product   Month     Billed     Revenue        COGS  Variance   Freight     Margin      %
  CP-ES200  2026-09       14   58,800.00   39,900.00      0.00  1,800.00  17,100.00  29.1%
  CP-ES200  2026-10       16   67,200.00   48,450.00    388.00  2,100.00  16,262.00  24.2%
  CP-ES300  2026-09        4   24,600.00   16,400.00      0.00  1,000.00   7,200.00  29.3%
  CP-ES300  2026-10        5   30,750.00   20,500.00  4,280.00  1,320.00   4,650.00  15.1%

  CP-ES200: margin -4.9 points vs September
      -4.2  1 pump issued, not billed: cost without revenue  [order 1000006: 1 pump delivered 2026-10-20, billing block AC]
      -0.6  production variance settled  [production order 1100015 settled, 388.00 USD to price differences]
      -0.1  freight per revenue dollar  [freight orders settled in October]
      +0.0  not explained by the rules above
  CP-ES300: margin -14.1 points vs September
     -13.9  production variance settled  [production order 1100016 settled, 1 pump scrapped at the pressure test]
      -0.2  freight per revenue dollar  [freight orders settled in October]
      +0.0  not explained by the rules above

  Draft commentary (built from the numbers; a controller reviews and signs it):
    CP-ES200 margin 24.2% in October vs 29.1% in September; largest driver: 1 pump issued, not billed: cost without revenue (-4.2 pts, order 1000006: 1 pump delivered 2026-10-20, billing block AC).
    CP-ES300 margin 15.1% in October vs 29.3% in September; largest driver: production variance settled (-13.9 pts, production order 1100016 settled, 1 pump scrapped at the pressure test).

  Synthetic numbers for a fictional company, not from an SAP system.

How to read it:

  • Every driver is computed, not guessed. One ES-200 was issued and not billed, so October carries its 2,850.00 USD cost without its revenue: 4.2 points of margin. The scrapped ES-300 on order 1100016 costs 13.9 points. Freight per revenue dollar barely moved.
  • The residual line is what the rules don't explain. Here it is close to zero, so the commentary can be signed. A large residual is the signal to dig, not to write around it.
  • The draft commentary is one sentence per product, built from the driver table, with the evidence attached. A language model can make it read better; it must not change the numbers or invent a cause. That is the pattern of the portfolio project finance variance assistant: compute in code, cite the postings, let the model write words.
  • A better risk model for the close board would learn from task history, upstream open items and volumes, as in classification and the metrics that matter. The late-count rule is the baseline it has to beat.
  1. Write the close report:

    • Windows (PowerShell):

      python unit1b\record_to_report.py --write
    • macOS / Linux:

      python unit1b/record_to_report.py --write
  2. The last line should read wrote unit1b/calder_close_report.md. Open the file in VS Code and press Ctrl+Shift+V (Windows, Linux) or Cmd+Shift+V (macOS) to preview it: the task graph as a diagram, the task table, the open issues and the draft commentary.

Step 9 (optional): Read journal entry items 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\record_to_report.py --sandbox
    • macOS / Linux:

      python unit1b/record_to_report.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> journal entry items read (SAP demo data, not Calder's)

  <G/L account> <account name>                  <amount> <currency>

  lines with a trading partner: <count> of <n>
  The sample is the first 100 items, not a balance: don't read it as a trial balance.

The script reads up to 100 items from API_JOURNALENTRYITEMBASIC_SRV with $select and sums them by G/L account. A valid "empty" result is 0 journal entry items read. The sandbox is read-only. The sum of 100 items is not a balance: a real trial balance reads a whole period, filtered by ledger and company code.

Step 10: Save your work in Git

git add unit1b/record_to_report.py unit1b/calder_close_report.md
git commit -m "Unit 1B: record-to-report close board, accruals, matching and margin drivers for Calder"

What each part of the code does

Part What it does
TASKS, LATE_HISTORY The close task list: owner, planned workday, predecessors, status; how often each task was late before
issues() Open items from the earlier topics, each tied to the task it hits, with an owner and an expected fix day
schedule(), critical_path(), risk() Projects each task's finish from plan, blockers and predecessors; finds the chain that decides the close; rates risk
PO_ACCRUAL_ITEMS, MANUAL_ACCRUALS, GRIR_OPEN Planned and actual costs per PO, accrual objects, and the GR/IR open items
print_allocation() The utilities cycle with and without the maintenance order that can't settle
CP10_ITEMS, CP20_ITEMS, match_intercompany() Both companies' intercompany items and three matching rules applied in order
MARGIN, drivers(), commentary() Margin by product and month, computed drivers with evidence, and a draft sentence per product
--write, write_report() The close report as Markdown with a task diagram
sandbox() Reads up to 100 sandbox journal entry items with $select and sums them by account

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 Intercompany partners ... are missing The pack was edited Run Step 2 again
KeyError with a task ID A task names a predecessor that isn't listed above it in TASKS Keep predecessors above the tasks that need them, or copy the script again
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 or entity 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), CP10's ledger, controlling, accruals, allocations and margin analysis run in the CP10 S/4HANA system. CP20 closes its own books on ECC and sends a reporting package. Group reporting consolidates both. A close orchestration tool holds the plan and starts jobs in both systems. Anything that predicts, matches or drafts reads released interfaces and writes proposals; accountants post and sign in SAP's own apps.

flowchart LR
  S4[S/4HANA CP10<br/>ledger, CO, margin,<br/>accruals] -->|release| GR[Group reporting]
  ECC[ECC CP20<br/>local close] -->|upload<br/>package| GR
  OR[Close orchestration<br/>task lists] -->|start jobs,<br/>status| S4
  OR -->|start jobs,<br/>status| ECC
  S4 -->|released APIs,<br/>CDS| AI[Close assistant<br/>read-only]
  AI --> P[Proposals:<br/>accruals, matches,<br/>commentary]
  P -->|accountant posts| S4
  GR --> DW[Analytics and<br/>data warehouse]

Non-functional requirements that decide the design

Requirement Typical target If missed
Close duration Local close by a fixed workday, group close a day or two later Decisions on old numbers; overtime every month
Cut-off discipline Every upstream task due before the close starts has an owner and a deadline Settlement and allocation run on incomplete data
Auditability Every accrual, adjustment and approval traceable to its basis and approver Audit findings; restatements
Segregation of duties The person who proposes an accrual or adjustment isn't the one who approves it Control failures
Data volume and latency Close reports read a period's journal in minutes, not overnight Reviews squeezed into the last hours
Availability at peak Systems and jobs sized for close days, when everyone runs reports at once Jobs fail on WD2 and the plan slips

Integration and identity. SAP Advanced Financial Closing runs in an SAP BTP subaccount, uses OData services to reach S/4HANA systems and a REST service for SAP ERP, doesn't replicate financial data, and needs an identity provider (SAP Learning). Group reporting takes integrated S/4HANA data by release and other companies' data by upload or data collection (SAP Learning). A close assistant should read journal entries with the accountant's own identity, so company code and authorization limits apply (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 close spans an S/4HANA company and an ECC company. Today it runs on a spreadsheet of tasks, and the local close slips most months because of upstream problems found on WD2. CP20 will stay on ECC for at least another year.
  • Options: (a) keep the spreadsheet and add a weekly pre-close review; (b) use the Financial Closing cockpit in each system separately; (c) one close plan in a BTP-based orchestration tool connected to both systems, with pre-close checks for the known upstream issues.
  • Decision: (c), with the pre-close checks from (a). One plan shows the cross-company dependencies, such as CP20's package feeding the intercompany reconciliation, and the pre-close checks catch missing cost centers and unapproved services before WD1.
  • Consequences: A separate subscription and a BTP subaccount to run. Task owners in two companies maintain one template. Pre-close checks need read access to logistics data, owned by finance but agreed with each process owner. Any AI that predicts task risk or proposes accruals reads from the same plan and never posts by itself.

What a design review will ask

  1. Which upstream conditions are checked before the close starts, and who owns each one?
  2. Where does each company's data enter group reporting, and how current is it on group close day?
  3. Who can post accruals and adjustments, who approves them, and is the split enforced in SAP roles?
  4. Which reports read the Universal Journal directly and which read a data warehouse, and how are they reconciled?
  5. Can any automated component post, adjust or close a period without a person?

Where AI fits, and where it doesn't

Step Pattern Fit Note
Close planning Predict Moderate Predict which tasks will be late from history, upstream open items and volumes; beat the late-count baseline from Step 4 first
Pre-close checks Plain automation is enough Weak for AI Missing cost centers, unapproved services and billing blocks are queries, not predictions
Accruals Predict and extract Strong Propose amounts from history, contracts and policy documents, with the basis shown; SAP lists an Accounting Accruals Agent for this (SAP). The accountant confirms
GR/IR clarification Predict (classify) and retrieve Strong Suggest root cause and next step per item; SAP offers machine-learning proposals with a separate subscription (SAP Learning)
Settlement and allocation Plain automation is enough Weak for AI Rules on master data; AI can flag what will fail, not choose receivers
Intercompany matching Match and explain Strong Rules first, then suggestions for what rules miss, with reason codes; SAP names an Intercompany Matching and Reconciliation Agent (SAP)
Anomalies in journal entries Detect Strong Unusual manual postings at period end, as in anomaly detection
Variance commentary Explain (generate from computed drivers) Strong Numbers from code, words from the model, citations to postings; see the finance variance assistant
Posting, adjusting, closing the period Act Never alone A person approves every posting and closes the period

The line is the same at every step: AI predicts, proposes, matches and drafts; accountants approve postings, controllers sign commentary and the period is closed by a person. Human approval in AI workflows and agent permissions show how to enforce that in code.

Pitfalls

  • Treating the close as a checklist. Write down the dependencies. A list hides that allocation needs settlement first.
  • Finding upstream problems on WD2. Run pre-close checks in the last week of the month: settlement receivers, unapproved services, billing blocks, open goods movements.
  • Reviewing GR/IR by balance. Opposite items cancel out. Review by item and age.
  • Accruals without a basis. Every estimate needs a source and a reviewer, or it becomes a plug.
  • Intercompany postings without trading partners. They can't be matched or eliminated. Make the trading partner come from master data on intercompany customers and suppliers, not from memory.
  • Explaining margin from memory. Compute drivers from postings and keep a residual line; a commentary that explains more than the change is wrong.
  • Letting an agent post at period end. Proposals are cheap to review and expensive to reverse once figures are reported.
  • Assuming CP20 works like CP10. ECC has no Universal Journal, and its data reaches group reporting by upload; plan the group calendar around that.

Exercise

Turn the close board into a controller's action note and test two fixes yourself.

  1. Run python unit1b/record_to_report.py --write (Windows: python unit1b\record_to_report.py --write). Copy unit1b/calder_close_report.md to unit1b/calder_close_actions.md.
  2. In the copy, add a column Action to the open issues table. For each issue write one sentence: what the owner should do today.
  3. Add a column AI could with one of: predict, propose, match, explain or nothing. Use the table under Where AI fits to choose.
  4. In record_to_report.py, change COMPRESSOR_COST_CENTER = "" to COMPRESSOR_COST_CENTER = "CP11-460". This is master data giving the compressor the utilities cost center (a course value).
  5. In CP20_ITEMS, find the item with reference 90000051 and change its "PartnerCompany": "" to "PartnerCompany": "CP10". This is CP20 reposting the IT charge with its trading partner.
  6. Run the script without options, then with --allocation and --intercompany. Note the projected local close, the critical path, the allocation total and how the IT charge is now matched.
  7. Change both edits back.
  8. In the action note, add a heading Fixes tested with three lines: what the cost center changed, what the trading partner changed, and which task now paces the close.
  9. Add a heading ECC note with one line on what CP20 must deliver, and by when, for the group close on WD4. Preview the file and commit it with Git.

Done when: unit1b/calder_close_actions.md has the five-line issue table with Action and AI could filled in for every line, a Fixes tested section that says the local close is projected on WD3 again, the critical path becomes X01 -> C13 -> C15 -> C16 -> G01 (CP20's package now paces the close), the allocation sends 10,240.00 USD (6,144.00 to CP11-410, 4,096.00 to CP11-420) and the IT charge is matched by rule 1, and an ECC note. Keep it: the capstone at the end of Unit 1B traces one exception across the whole enterprise, and this note is its finance part.

Check yourself

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

1The close board projects settlement on WD4 because order 4000112 can't settle. Why does the margin review move too?

Answer: A. Delays travel along predecessors. The allocation needs the repair cost on the utilities cost center, and the margin review needs the allocation, so one blocked settlement moves both.

2In SAP's purchase order accruals, nobody reviews the 3,800.00 USD proposal for the crane inspection. What happens?

Answer: D. Review is optional, and an unreviewed proposal posts as proposed. Only an approval step holds an amount back, which is why the PO owner should still check it.

3Calder's GR/IR net balance is 3,185.00 USD credit. Why is that number alone not enough?

Answer: C. Five motors received and not invoiced and four impellers invoiced and not received point opposite ways. Each needs its own owner; the net hides both.

4An intercompany pair matches on reference and amount, but CP20's side has no trading partner. Why flag it instead of accepting it?

Answer: D. Elimination relies on the trading partner to know that both sides belong to the group. Until CP20 reposts it, the payable sits in group figures as a debt to a stranger.

5Why does the script match intercompany items in transaction currency rather than in group currency?

Answer: C. Both invoices are in USD, so they agree to the cent there. In group currency, CP20's EUR books translated at different rates would add differences that need their own rule.

6ES-200 margin fell 4.2 points because one pump was delivered with a billing block. What is happening in the books?

Answer: B. Cost of goods sold posts at goods issue and revenue at billing. A delivery that can't be billed at month end puts cost in one month and revenue in the next.

7You add a language model to write the variance commentary. Which design keeps it trustworthy?

Answer: C. The numbers come from postings and code; the model only phrases them, and a check confirms every number in the text matches the driver table.

8A team wants an agent to post accruals, reconcile and close the period overnight on WD1. What do you design instead?

Answer: A. Postings and closing the period change reported figures. The agent prepares and explains; people approve each posting and close the period, with a record.

Practice this in the lab

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

  • Stage 11 of 11Pay and close the monthThe whole stage practices this topic, on Posting periods, Journal entry and Reports.
  • Stage 1 of 11Found the companyPractice it on Charts of accounts.
  • Stage 2 of 11Set up financePractice it on G/L accounts, Posting periods, Cost centers and 1 more.
  • Stage 4 of 11Create master dataPractice it on Material accounting data.
  • Stage 10 of 11Invoice and collectPractice it on Reports.
  • ChapterProduct lifecycleChange how a product is built without stopping the line: an engineering change record with a reason and a date, a redesigned ES-200 impeller with its plant data, price and supplier, and a bill of material with two dated lives, the old impeller ending and the new one starting on the change's date, so every order reads the bill valid on the day it starts; then the planner runs MRP across the date and reads the old impeller's run-out and the new one's first order.
  • ChapterQualityInspect what Calder buys before anyone uses it: an inspection setup and characteristics for seal kits, goods receipts that go to quality inspection and open inspection lots, results measured on a sample, and the usage decision that releases a good delivery to unrestricted stock or blocks a bad one, while planning counts only what can be used. Then the final inspection of a pump Calder builds: ES-300 tested at production receipt before anyone can sell it, and an order that confirms only the pumps that passed until a recheck after the decision. Then the complaint: a supplier's invoice blocked for quality, a quality notification, a return delivery and the credit memo that settles it. Then the supplier's replacement inspected and its invoice released by the decision, and the supplier's quality record read for what it can and can't say. Last, ES-300's final inspection fed by the test bench: one measurement per pump, recorded once against its serial number.
  • ChapterShop floorRun Rockford's floor from its own system: released production orders become dispatch lists per work center, in operation order with their scheduled dates, hours and the parts to issue; operators start and finish each operation by shift and confirm it with the good quantity, the scrap and its reason, and the hours; cheap parts are backflushed by the confirmation; the test bench's machine time is costed apart from the technician's; every pump gets a serial number and its own bench results; a pump that fails twice is repaired on its own rework order; every stop gets a reason, each work center's week reads as OEE, a breakdown goes to the maintenance planner, and in the end the floor runs its own system, fed by messages from the core, including cancellations the floor must agree to, and sending its confirmations back the same way, each of which can fail and must be fixed.
  • ChapterTransportationPlan Calder's freight from Rockford: carriers and their agreements, freight units from deliveries, freight orders, the carrier chosen by cost, loads with stops, truck capacity and a departure worked back from the dates, the final charges with stops and detention after free time, tendering each load to a carrier that has to accept it, following the truck by its execution events, and settling the freight: each carrier invoice checked against the agreed charges, the freight cost shared among the deliveries so each customer's margin shows what its freight really cost, and inbound freight on purchased motors planned on the purchase order and carried into the motors' cost.
  • ChapterWarehouseLay out Calder's Rockford distribution center: storage types, sections and bins, then put stock away, pick, pack and count it, put people on the work and watch it.
  • DrillThe goods receipt that won't postFinance, about 5 minutes
  • DrillThe shipment that won't leave the dockFinance, about 6 minutes
  • DrillThe seal kits that can't be valuedFinance, about 5 minutes
  • AI stopBuildingAt the end of stage 7, "Build".

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

Sources

Sign in to track your progress

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

or

Tell us a little about you

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

SAP areas you work in