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.

Order to cash end to end

Follow a customer order from quotation to cleared payment in SAP, see where orders get stuck and who frees them, and decide where AI helps.

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

The 60-second version

Order to cash is everything between a customer asking for something and the money arriving in the bank. In SAP it runs as a chain of documents: quotation, sales order, delivery, goods issue, invoice and payment.

At each link, the system checks something before it lets the order move on:

  • Price: what does this customer pay for this product today?
  • Availability: can we ship the quantity on the date they want?
  • Credit: is it safe to ship more to this customer?
  • Completeness: is every field the next step needs filled in?

When a check fails, the order stops and waits for a person. A credit analyst, a pricing clerk or a planner frees it. Meanwhile the goods sit in the warehouse and the invoice isn't sent.

Three ideas carry this topic:

  • The order is a promise, not money. Cash only moves when goods ship, the invoice goes out and the customer pays.
  • Stuck orders are the cost. Each block is a control that protects the company, but every hour an order waits delays revenue and cash.
  • Every step has an owner. Sales, shipping, billing, credit and accounts receivable each own one part. Most problems sit on the handoffs between them.

Why it matters to the business

Order to cash decides how fast sales become cash. Finance tracks this as days sales outstanding (DSO): how long, on average, customers take to pay. Sales tracks on-time delivery. Both depend on orders flowing without stops.

Here is a snapshot from Calder Pumpworks, the fictional pump maker this unit follows. On 23 October 2026, Calder's order-to-cash worklist shows seven items stuck:

What is stuck Why Who frees it Value held (USD)
Six pumps for distributor Hallam Over the credit limit Credit analyst 24,288.00
Five ES-300 pumps for Brennock Only one can ship on the date wanted Order fulfillment with planning 24,600.00
Two ES-300 pumps for Brennock Payment terms missing on the order Sales order team 12,300.00
Two ES-200 pumps for Brennock Customer asked to hold the delivery Customer service 8,400.00
One pump already shipped Invoice blocked until the customer accepts it Billing 4,200.00
40 seal kits for Hallam No price for the aftermarket channel Pricing team not priced
A short payment from Hallam Customer paid 1,150 less than invoiced Accounts receivable 1,150.00

The business decision is not "remove the blocks". A credit block that stops shipments to a customer who can't pay is doing its job. The decision is how fast each block reaches the right person, with the facts they need. That is also where most AI proposals for order to cash aim.

How SAP does it

As of October 2026, SAP S/4HANA covers the whole chain as standard. SAP's sell-from-stock process runs from a sales order with pricing and an availability check, through picking and goods issue, to the invoice and the payment that clears the customer's account (SAP Learning). Apps such as Manage Sales Orders - Version 2 and Sales Order Fulfillment Issues show the orders and their problems.

  • Pricing uses the condition technique: a set of rules that finds the right price, discount and surcharge records for each item (SAP Learning).
  • Availability (ATP, available-to-promise) checks stock and planned receipts when the order is entered. The advanced version, aATP, adds backorder processing, product allocation and release for delivery (SAP Learning). Licensing differs by edition. In SAP S/4HANA Cloud Public Edition, basic ATP is in the standard license and the advanced functions need an extra one. In Private Edition and on-premise, aATP must be activated and also needs an additional license (SAP Learning).
  • Credit runs in SAP Credit Management. Basic credit checks are included. Scorecards, automatic credit limits, workflow and external credit data need a separate license (SAP Learning). A credit analyst releases or rejects blocked orders in Manage Documented Credit Decisions (SAP Learning).
  • Receivables, disputes and collections live in finance. A customer's short payment can open a dispute case linked to the open invoice (SAP Learning).

SAP has also released AI features for this process. In its Q1 2026 release highlights, SAP lists these for SAP S/4HANA Cloud Public Edition (SAP News):

  • AI-assisted sales order creation from PDF or image purchase orders (generally available)
  • AI-assisted processing of payment advices (generally available)
  • A Dispute Resolution Agent (beta in Q1 2026; by October 2026 SAP described its general availability as rolling out in phases, SAP News)

Where AI creates value in S/4HANA places these in the wider picture.

The process on one page

flowchart LR
  Q[Inquiry and<br/>quotation] --> SO[Sales order<br/>price, ATP, credit]
  SO --> DL[Outbound delivery<br/>pick, pack]
  DL --> GI[Goods issue]
  GI --> BD[Invoice]
  BD --> AR[Open receivable]
  AR --> PAY[Payment<br/>clears it]
  AR -.-> DSP[Dispute or<br/>collections]
  SO -.-> RET[Returns and<br/>credit memo]
Step Team Document created Posts to finance?
Customer asks for a price Sales Inquiry, quotation (optional) No
Customer orders Sales (inside sales) Sales order with schedule lines No
Credit check fails Credit management Credit block on the order; a credit decision No
Warehouse prepares Shipping, warehouse Outbound delivery, picking No
Goods leave Shipping Goods issue (material document) Yes: cost of goods sold
Customer is billed Billing Invoice (billing document) Yes: receivable and revenue
Customer pays Accounts receivable Payment, clearing Yes: bank against receivable
Customer pays less Accounts receivable Residual item, dispute case Yes: the residual item
Customer returns goods Sales, warehouse, billing Returns order, returns delivery, credit memo Yes: at goods receipt and credit memo

Which steps post, and to which accounts, is the subject of Document flow and how logistics becomes finance.

Questions to ask

  1. How many sales orders are blocked today, for which reasons, and how old is the oldest?
  2. Who owns each kind of block, and do they get a work list or find out by email?
  3. Where is the credit check: at order entry, at delivery, at goods issue, or several? Why there?
  4. What happens when ATP can't confirm the full quantity: partial delivery, wait, another plant? Who decides?
  5. Which customers send orders by EDI, which by PDF or email, and how much keying does that leave?
  6. What share of payments clear automatically, and how long do short payments stay unresolved?
  7. If an AI tool proposes releasing a blocked order, who approves it, and is that logged?

Common misconceptions

  • "A sales order is revenue." It is a commitment. Revenue posts at billing, and cash arrives at payment.
  • "Blocks are errors to eliminate." Most blocks are controls. The goal is fast, well-informed decisions, not zero blocks.
  • "ATP says the stock is there." ATP confirms against stock and planned receipts, minus what earlier orders already claimed. A confirmed date can rely on a receipt that hasn't happened yet.
  • "Credit management is a finance topic only." It decides whether sales can ship. Sales, shipping and finance all feel a credit block.
  • "An AI agent should release blocked orders." It can prepare the decision and explain it. Releasing credit is a person's decision with an audit trail.

Key terms

All course terms, A to Z, in the glossary

  • Order to cash (O2C): the end-to-end process from customer request to cleared payment.
  • Quotation: an offer to a customer, with prices and dates, which an order can copy.
  • Schedule line: the part of an order item with a confirmed date and quantity.
  • Condition technique: SAP's rule mechanism for finding prices, discounts and surcharges.
  • ATP (available-to-promise): the quantity that can still be promised to new orders.
  • Credit exposure: what a customer already owes or has ordered, measured against their credit limit.
  • Billing block / delivery block: a reason set on an order that stops invoicing or shipping until someone removes it.
  • Incompleteness log: the list of fields missing for a later step.
  • Residual item: what stays open on a customer's account after a short payment.
  • DSO (days sales outstanding): the average number of days customers take to pay.

Check yourself

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

1Calder saves a sales order for six pumps. What has changed in the company's books?

Answer: C. The sales order posts nothing in finance. Cost appears at goods issue, the receivable and revenue at billing, and cash at payment.

2Hallam's new order goes over its credit limit and is blocked. What is the best way to treat the block?

Answer: C. A credit block protects Calder from shipping to a customer who may not pay. The business goal is a fast, informed decision by the credit analyst, not removing the control.

3An order item shows one pump confirmed for the date wanted and four for two weeks later. What happened?

Answer: C. ATP confirms what is free on the requested date and proposes later dates for the rest. The split shows as schedule lines, and only confirmed lines can be delivered.

4An order can be delivered but the invoice can't be created. Which cause fits?

Answer: C. SAP's incompleteness checks are typed by step. Missing billing data, such as payment terms, lets the order ship but stops the invoice.

5A vendor pitches an AI agent that releases credit-blocked orders automatically. What should you ask for?

Answer: A. Releasing credit is a financial decision with an audit trail. An agent can gather the exposure, payment history and order facts and recommend, while a credit analyst decides.

6Calder's customers take longer to pay this quarter. Which order-to-cash measure shows it directly?

Answer: D. DSO measures how long customers take to pay after billing. Blocked orders delay billing, which hurts cash too, but DSO is the direct measure of payment speed.

7Which part of order to cash did SAP list as generally available AI for S/4HANA Cloud Public Edition in Q1 2026?

Answer: D. SAP's Q1 2026 release highlights list AI-assisted sales order creation from unstructured purchase orders as generally available. The Dispute Resolution Agent was beta, and none of the other options were announced.
Deep layer · 40 min read

Mental model

Think of order to cash as one promise, made more specific at every step, with a gate in front of each step.

  • The quotation promises a price.
  • The sales order promises a quantity, a date and a price to one customer.
  • The delivery promises those goods leave from this dock.
  • The goods issue says they left.
  • The invoice turns the promise into a claim on the customer.
  • The payment settles the claim.

Each gate is a check that runs on data other teams own: price records from pricing, stock and receipts from planning, credit limits from credit management, terms from the business partner. When a gate closes, the order carries a status that says why. Every block, worklist and AI triage tool in this process reads those statuses. If you know which gate closed and who owns its data, you know who has to act.

Business objects and documents

Before you start: this topic builds on The SAP data model for AI engineers (header and item, released views), How an SAP enterprise is wired (sales areas, plants, shipping points), Master data that every module depends on and Document flow and how logistics becomes finance. It doesn't repeat them.

Pre-sales: inquiry and quotation

Inquiries and quotations are SAP's two pre-sales document categories. An inquiry records what a customer asked about; a quotation is the offer. SAP notes that using them is not mandatory (SAP Learning). When a quotation is only partly ordered, a reason for rejection on the remaining items closes it. The reasons ("too expensive", "delivery date too late") are kept, so lost business can be analyzed.

An order created with reference to a quotation copies its data under copying control, defined at header, item and schedule-line level, with requirements such as "same sold-to party" and "item not rejected" (SAP Learning). The reference is what lets document flow show "this order came from that quotation".

The sales order: header, item, schedule line

Every sales document belongs to a sales area (sales organization, distribution channel, division) and needs a customer and at least one item. The user enters the sold-to party, material and quantity; the system proposes the rest from master data (SAP Learning).

Level Holds Calder example (fictional)
Header Sold-to party, sales area, payment terms, incoterms, blocks, overall statuses Order 1000001, Brennock, CPUS/10/PU, NT30
Item Material, quantity, item category, plant, net value, rejection reason, billing block Item 10: 4 × CP-ES200 from plant CP11
Schedule line Requested and confirmed date and quantity 4 confirmed for 2026-09-28
Pricing elements Each condition applied, with its type, rate and amount Brennock's contract price for CP-ES200

Every item that is physically delivered needs at least one schedule line, and there can be several when the quantity is confirmed in parts (SAP Learning). In SAP's model of the Sales Order API, the schedule line carries RequestedDeliveryDate, ConfirmedDeliveryDate, ScheduleLineOrderQuantity, ConfdOrderQtyByMatlAvailCheck and its own DelivBlockReasonForSchedLine. The header carries DeliveryBlockReason, HeaderBillingBlockReason, TotalCreditCheckStatus and OverallSDProcessStatus; the item carries ItemBillingBlockReason and SalesDocumentRjcnReason (SAP Cloud SDK model, 2022).

Pricing: the condition technique

The order's pricing procedure is chosen from the sales area, a key on the order type and a key on the customer. The procedure lists the allowed condition types (price, discounts, surcharges, freight, tax) in sequence, with subtotals. Each condition type has an access sequence: an ordered list of condition tables, each with its own key, such as customer plus material, then material alone. The search stops at the first valid condition record (SAP Learning). A condition can be marked mandatory; if a mandatory price is not found, pricing is incomplete and the order can't proceed normally.

This is why "the price is wrong" usually means "a different record was found first", or "no record exists for this key". Condition records are master data, covered in the master data topic.

Availability: ATP and aATP

If the check is active for the material, it runs when the order is created. It compares the requested quantity with the cumulative ATP quantity on the requirement date in the delivering plant. If it is covered, the quantity is reserved and the schedule line is confirmed. If not, the system proposes later dates or a partial confirmation, or leaves the line unconfirmed. SAP's lesson is blunt: only confirmed schedule lines can be delivered (SAP Learning).

Advanced ATP (aATP) builds on the same concept and adds (SAP Learning):

Capability What it does Calder use
Product availability check The basic check, with scope of check and scheduling Every pump order
Backorder processing Re-checks confirmations when supply or demand changes A test-bench outage delays ES-300 output
Product allocation Caps how much a customer group or region can get Ration ES-300 pumps between utilities and distributors
Alternative-based confirmation Confirms from another plant Not used: only CP11 assembles pumps
Release for delivery Lets an order fulfillment manager prioritize scarce stock across due orders Month-end shortages

A related feature, supply protection, reserves quantities for groups of customers in time buckets (SAP Learning). Check the edition and license before designing on any of these: the licensing note under How SAP does it applies.

Credit management

In S/4HANA, credit checks from sales call SAP Credit Management (FIN-FSCM-CR). Each customer needs the business partner role UKM000 for it. Credit limits and exposure are kept per credit segment, and each credit control area maps to one segment. For sales document types, only credit check type D (or no check) remains (SAP Learning).

Checks can be set for sales document types and for delivery types, at delivery creation and at goods issue (SAP Learning). An order above the limit is blocked. A credit analyst opens it in Manage Documented Credit Decisions and chooses Release, Recheck or Reject (with a reason). Manage Credit Accounts shows the partner's credit profile, and Manage Credit Limit Requests handles limit changes with approval (SAP Learning).

Incompleteness and blocks

The incompleteness log lists fields missing for a later step. Incompleteness procedures are typed: general, delivery, billing, price, goods movement, picking and packing. An order missing billing data, such as payment terms, can still be delivered but not billed (SAP Learning).

Blocks are deliberate stops. A sales document type can propose a delivery or billing block by default (SAP Learning), and a customer master can carry an order block (SAP Learning). A billing block stops the invoice and writes an error log when billing runs (SAP Learning). Block reasons are configuration: read their codes and texts from the system.

Delivery, picking and goods issue

An outbound delivery references at least one sales order. It controls picking and packing (both optional), transport planning (optional) and goods issue. A delivery has no schedule lines; each order schedule line due for shipping can become a delivery item. The Pick Outbound Delivery app copies the picked quantity into the delivery, after which goods issue can be posted. Goods issue changes stock quantity and value, creates the accounting documents and puts the delivery on the billing due list (SAP Learning). With extended warehouse management, a partial goods issue isn't possible; the delivery is split instead (SAP Learning). The warehouse side is covered in Warehouse and transportation execution.

Billing, output and receivables

Goods are billed with reference to the delivery; services with reference to the order. Saving the invoice creates the accounting documents and updates profitability analysis, statistics and credit exposure (SAP Learning). Billing runs from the billing due list, collectively or one by one, or in background jobs, using apps such as Create Billing Documents - Billing Due List Items and Manage Billing Documents (SAP Learning).

The invoice is sent by output management. In S/4HANA, the default is SAP's output management based on BRFplus rules, with print, email, XML and (on-premise) IDoc channels. ECC used the condition technique with table NAST, which can be re-enabled in S/4HANA through Customizing (SAP Learning).

Each invoice becomes an open item on the customer's account, posted through the reconciliation account in the customer master. Payment terms set the due date. When the customer pays, the payment and the invoice are cleared (SAP Learning). Payments arrive by electronic bank statement, lockbox in the US, or manual entry in Post Incoming Payments. A short payment is handled two ways (SAP Learning):

  • As a partial payment: the invoice stays open.
  • As a residual item: the invoice is cleared and a new item holds the difference.

Small differences can be written off automatically within tolerance groups.

Disputes, collections and returns

Payment deductions found while processing incoming payments can create a dispute case at once. A case can also be created from an open item, stays linked to it, and can be closed by a later payment (SAP Learning). Manage Dispute Cases records the reason, root cause, processor and an SLA date (SAP Learning). Collections management gives collectors a worklist ranked by overdue amounts, with promises to pay. It can show a machine-learning "Risk of Late Payments" column once that feature is enabled (SAP Learning).

Returns run backwards through the same chain. In advanced returns management, the returns order is the central document. It leads to a returns delivery, an inspection, a follow-up activity (back to stock, scrap, or ship to the supplier) and a credit memo request that becomes the credit memo. SAP states the advanced returns business functions are always on in S/4HANA (SAP Learning).

Document flow and postings

flowchart LR
  QT[Quotation<br/>20000001] --> SO[Sales order<br/>1000001]
  SO --> DL[Delivery<br/>80000001]
  DL --> GI[Goods issue<br/>4900000001]
  GI --> J1[Journal<br/>COGS vs stock]
  DL --> BD[Invoice<br/>90000001]
  BD --> J2[Journal<br/>receivable vs revenue]
  J2 --> PAY[Payment<br/>1400000001]
Step Posts to finance Why
Quotation, sales order Nothing A promise. Open order values can count toward credit exposure, but that is credit data, not a journal entry
Delivery, picking Nothing Logistics preparation
Goods issue Cost of goods sold against finished goods inventory, at the stock's valuation Stock left; the material's valuation sets the amount
Invoice Customer receivable against revenue (and output tax) The claim on the customer exists now
Payment Bank against the receivable; the open item is cleared The claim is settled
Short payment Residual item on the customer, or partial payment The rest is still owed, or disputed
Credit memo (return) Revenue reduction against the receivable The claim is reduced

At Calder (fictional), order 1000001 posts 19,600.00 USD of cost at goods issue and 29,100.00 USD of receivable and revenue at billing, then clears the receivable on 2026-10-20. The accounts and the rules that choose them are explained in Document flow and how logistics becomes finance, with its follow_the_money.py script.

Neighbors

Neighbor Gives order to cash Takes from order to cash When the link fails
Master data (business partner, material, conditions) Customer, terms, credit role, prices Nothing directly Orders incomplete or wrongly priced
Planning (MRP) and production Planned receipts that ATP can promise Sales order demand Promises nobody can keep, or stock nobody ordered
Inventory and warehouse Stock to promise and pick Deliveries, goods issues Orders confirmed but not shippable; goods issue stuck
Transportation Carrier, freight cost, shipping dates Deliveries to plan Late trucks, freight cost missing from margin
Credit management Credit decisions Order and invoice values that update exposure Over-shipping to risky customers, or blocking good ones
Finance (receivables, general ledger) Clearing, dunning, disputes Goods issue and billing postings Revenue without cost, unexplained receivables
Customer channels (EDI, portal, CRM, e-commerce) Orders, changes, payment advices Confirmations, delivery notes, invoices Orders lost or keyed twice; customers without confirmations
Intercompany (CP20 to CP10) Components bought between Calder companies Intercompany billing Unmatched intercompany balances (see intercompany and multi-company scenarios)

Where it breaks

Symptom Usual cause Who resolves it What it costs Calder (scenario)
Order 1000002 blocked for credit Hallam's open items plus new order exceed its 40,000 USD limit Credit analyst 24,288.00 USD of pumps held
Order 1000004 partly confirmed Only one free ES-300 on 2026-10-27; four more after a production receipt Order fulfillment, planning 24,600.00 USD late, customer date missed
Order 1000003 can't be billed Payment terms missing on the order Sales order team, master data 12,300.00 USD can ship but not invoice
Order 1000007 won't ship Delivery block: the customer asked to hold Customer service 8,400.00 USD waiting
Order 1000006 not invoiced Billing block until the customer accepts the pump Billing with sales 4,200.00 USD shipped, not billed
Order 1000005 unpriced No price record for seal kits in the aftermarket channel Pricing Order can't be valued
Invoice 90000002 short paid Hallam deducted 1,150.00 USD Accounts receivable, dispute processor Residual item and a dispute

How it is exposed

As of 23 October 2026, these released interfaces carry each step. The CDS views come from SAP's Cloudification Repository (file read 23 October 2026), which also marks the classic tables VBAK, VBAP, VBEP, LIKP and VBRK as not to be released and names successors such as I_SalesDocument (Cloudification Repository). The OData API names and fields come from SAP's generated API models (SAP Cloud SDK model, 2022).

Step OData API (A2X) Released CDS views What to read
Quotation API_SALES_QUOTATION_SRV I_SalesQuotation, I_SalesInquiry Validity, rejection reasons
Sales order API_SALES_ORDER_SRV; API_SALES_ORDER_SIMULATION_SRV to price without saving I_SalesOrder, I_SalesOrderItem, I_SalesOrderScheduleLine TotalCreditCheckStatus, DeliveryBlockReason, HeaderBillingBlockReason, schedule-line confirmations
Availability API_PRODUCT_AVAILY_INFO_BASIC (functions such as DetermineAvailabilityAt) Available quantity for a material and plant
Credit I_CreditBlockedSalesDocument, I_CreditManagementAccount, I_CreditExposureItem Which documents are blocked, exposure versus limit
Delivery API_OUTBOUND_DELIVERY_SRV (functions PickAllItems, PostGoodsIssue) I_OutboundDelivery, I_OutboundDeliveryItem Picking and goods movement status, incompleteness statuses
Billing API_BILLING_DOCUMENT_SRV I_BillingDocument, I_BillingDocumentItem AccountingDocument, AccountingTransferStatus, OverallBillingStatus
Receivables I_OperationalAcctgDocItem Open and cleared items per customer
Returns API_CUSTOMER_RETURN_SRV I_CustomerReturn Returns and their follow-up
Document flow Navigations to_PrecedingProcFlowDoc, to_SubsequentProcFlowDoc on the sales order I_SDDocumentMultiLevelProcFlow The chain from quotation to invoice

The functions are the API's own operations for picking and posting goods issue; use them only in a design where a person triggers the action. For events, SAP's integration training uses sap.s4.beh.salesorder.v1.SalesOrder.Created.v1 and ...Changed.v1 to replicate sales orders through SAP Event Mesh (SAP Learning). Event-driven agents with SAP events shows how to react to them.

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 does three things. It traces Calder's order 1000001 from quotation to payment and re-checks its prices with a small condition technique. It then runs the order-to-cash gates (pricing, availability, credit, completeness, blocks) on six more orders and an unpaid invoice. Finally, it prints the worklist a team lead would want each morning, and writes it to a file. An optional step counts blocked orders in SAP's sandbox.

flowchart LR
  P[calder_pack/<br/>order 1000001] --> S[order_to_cash.py]
  SC[Scenario in the script<br/>prices, stock, credit,<br/>orders 1000002-1000007] --> S
  S --> T[Trace and<br/>cycle times]
  S --> W[Worklist:<br/>what is stuck, who acts]
  S --> M[calder_o2c_worklist.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 6: no account, no key and no network. The script uses only Python's built-in modules, so requirements.txt doesn't change.
  • For Step 7 (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

Step 3: Create the script

  1. In VS Code, create a new file, paste the script below, and save it as order_to_cash.py inside the unit1b folder.
  2. Read the blocks PRICE_RECORDS, STOCK, CREDIT_LIMIT and ORDERS first. They are Calder's scenario written as Python data: price records, stock and receipts at plant CP11, credit limits and open orders. Every value is invented. Condition type names such as PRICE and block codes such as HC are the course's own labels, not SAP's.
"""Order to cash at Calder: trace one order end to end and triage the orders that are stuck.

Calder Pumpworks is a fictional company. The pack (unit1b/calder_pack/) and the
scenario below (prices, stock, credit limits, open orders) are made up for the
course. Field names follow SAP's released APIs where one exists; every code,
value and amount is illustrative, not SAP standard and not from any real system.

Run it from your course folder:
  python unit1b/order_to_cash.py                  trace order 1000001, then triage Calder's open orders
  python unit1b/order_to_cash.py --order 1000004  show every check for one order
  python unit1b/order_to_cash.py --write          write unit1b/calder_o2c_worklist.md
  python unit1b/order_to_cash.py --sandbox        count blocked orders in SAP's sandbox (needs a key)
Uses only Python's built-in modules. Only --sandbox needs a key and the network.
"""
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from datetime import date, timedelta
from pathlib import Path

HERE = Path(__file__).resolve().parent
PACK = HERE / "calder_pack"
OUT = HERE / "calder_o2c_worklist.md"
SANDBOX = "https://sandbox.api.sap.com/s4hanacloud/sap/opu/odata/sap/"
AS_OF = date(2026, 10, 23)  # fixed "today", so every run prints the same numbers

# --------------------------------------------------------------- pricing
# A tiny condition technique. Each condition type has an access sequence: a
# list of keys to try, most specific first. The first record found wins.
# Condition type names (PRICE, DISC) are course labels, not SAP's codes.
PRICE_RECORDS = {
    ("customer/material", "10000001", "CP-ES200"): 4200.00,  # Brennock contract price
    ("customer/material", "10000001", "CP-ES300"): 6150.00,
    ("channel/material", "10", "CP-ES200"): 4400.00,          # list price, direct
    ("channel/material", "10", "CP-ES300"): 6400.00,
    ("channel/material", "20", "CP-ES200"): 4400.00,          # list price, distributors
    ("channel/material", "20", "CP-ES300"): 6400.00,
    # No record for channel 30 (aftermarket): seal kits have no price there yet.
}
DISCOUNT_RECORDS = {("customer", "10000002"): 0.08}  # Hallam: 8% distributor discount


def price_item(customer: str, channel: str, material: str) -> dict:
    """Run the access sequences for one item and say which record was used."""
    price, found = None, "no record found"
    for table, key in (("customer/material", customer), ("channel/material", channel)):
        if (table, key, material) in PRICE_RECORDS:
            price = PRICE_RECORDS[(table, key, material)]
            found = f"PRICE from {table} {key}/{material}"
            break
    discount = DISCOUNT_RECORDS.get(("customer", customer), 0.0)
    return {"price": price, "discount": discount, "found": found}


# ---------------------------------------------- availability (cumulative ATP)
# Plant CP11, storage location FG01, after order 1000001 has shipped.
STOCK = {"CP-ES200": 12, "CP-ES300": 3, "CP-SK200": 300}
RECEIPTS = {"CP-ES300": [(date(2026, 11, 6), 4)]}  # a production order finishing


class Availability:
    """Cumulative ATP with look-ahead: a new order may not take stock that an
    earlier confirmed order needs later. Orders are checked in creation order."""

    def __init__(self):
        self.confirmed = {m: [] for m in STOCK}  # material -> [(date, qty)]

    def _free(self, material: str, day: date) -> int:
        events = sorted({day} | {d for d, _ in RECEIPTS.get(material, [])}
                        | {d for d, _ in self.confirmed[material]})
        free = None
        for t in (e for e in events if e >= day):
            supply = STOCK[material] + sum(q for d, q in RECEIPTS.get(material, []) if d <= t)
            demand = sum(q for d, q in self.confirmed[material] if d <= t)
            free = supply - demand if free is None else min(free, supply - demand)
        return max(free or 0, 0)

    def check(self, material: str, qty: int, wanted: date) -> list:
        """Return schedule lines [(date, confirmed qty)]; date None means unconfirmed."""
        lines, open_qty = [], qty
        days = [wanted] + sorted(d for d, _ in RECEIPTS.get(material, []) if d > wanted)
        for day in days:
            take = min(open_qty, self._free(material, day))
            if take:
                lines.append((day, take))
                self.confirmed[material].append((day, take))
                open_qty -= take
            if not open_qty:
                break
        if open_qty:
            lines.append((None, open_qty))
        return lines


# ---------------------------------------------------------- credit (simplified)
# One credit segment for Calder USA. Exposure here = open receivables plus open
# order value; SAP Credit Management has more categories and rules.
CREDIT_LIMIT = {"10000001": 250000.00, "10000002": 40000.00}
OPEN_RECEIVABLES = {  # invoice, customer, amount still open (USD)
    "90000002": ("10000002", 1150.00),   # residual item after a short payment (disputed)
    "90000003": ("10000002", 20240.00),  # not yet due
}

# ------------------------------------------------------------ open orders
# Orders 1000002 onwards are scenario data for this topic, not in the pack.
# Each tuple: (order, sold-to, channel, division, payment terms, requested date,
#              [(item, material, quantity)], delivery block, billing block, shipped)
ORDERS = [
    ("1000002", "10000002", "20", "PU", "NT45", date(2026, 10, 30), [("10", "CP-ES200", 6)], "", "", False),
    ("1000003", "10000001", "10", "PU", "", date(2026, 11, 2), [("10", "CP-ES300", 2)], "", "", False),
    ("1000004", "10000001", "10", "PU", "NT30", date(2026, 10, 27), [("10", "CP-ES300", 5)], "", "", False),
    ("1000005", "10000002", "30", "SP", "NT45", date(2026, 10, 28), [("10", "CP-SK200", 40)], "", "", False),
    ("1000006", "10000001", "10", "PU", "NT30", date(2026, 10, 20), [("10", "CP-ES200", 1)], "", "AC", True),
    ("1000007", "10000001", "10", "PU", "NT30", date(2026, 11, 9), [("10", "CP-ES200", 2)], "HC", "", False),
]
BLOCK_TEXT = {"HC": "customer asked to hold", "AC": "waiting for customer acceptance"}
OWNER = {
    "credit block": "Credit analyst (credit decisions)",
    "billing data incomplete": "Sales order team, with master data",
    "partly confirmed": "Order fulfillment, with production planning",
    "price missing": "Pricing team",
    "billing block": "Billing clerk, with sales",
    "delivery block": "Customer service",
    "short payment": "Accounts receivable (dispute case)",
}


def load_pack() -> dict:
    try:
        return {name: json.loads((PACK / f"{name}.json").read_text(encoding="utf-8"))
                for name in ("business_partners", "products", "order_1000001")}
    except FileNotFoundError:
        sys.exit(f"Cannot read {PACK}. Run python unit1b/make_calder_pack.py from your course folder first.")


def names(pack: dict) -> dict:
    return {bp["BusinessPartner"]: bp["BusinessPartnerFullName"]
            for bp in pack["business_partners"]["BusinessPartners"]}


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


def risk_text(kind: str, amount: float) -> str:
    return "unpriced" if kind == "price missing" else money(amount)


# --------------------------------------------- part 1: one order, end to end
def trace_1000001(pack: dict, show: bool = True) -> list:
    """Rebuild order 1000001's story from the pack and re-check its prices."""
    docs = pack["order_1000001"]
    so, dl, gi, bd = (docs["SalesOrder"], docs["OutboundDelivery"],
                      docs["MaterialDocument"], docs["BillingDocument"])
    pay = next(j for j in docs["JournalEntries"] if j["AccountingDocument"] == "1400000001")
    ordered = date.fromisoformat(so["SalesOrderDate"])
    issued = date.fromisoformat(gi["PostingDate"])
    billed = date.fromisoformat(bd["BillingDocumentDate"])
    paid = date.fromisoformat(pay["PostingDate"])
    due = billed + timedelta(days=int(so["CustomerPaymentTerms"][2:]))  # NT30 -> 30 days, Calder's key
    steps = [
        ("2026-09-08", "Quotation 20000001 (scenario)", "Sales", "Prices offered, valid to 2026-09-30"),
        (so["SalesOrderDate"], f"Sales order {so['SalesOrder']}", "Sales",
         f"Net {money(so['TotalNetAmount'])} {so['TransactionCurrency']}, terms {so['CustomerPaymentTerms']}"),
        (dl["PlannedGoodsIssueDate"], f"Outbound delivery {dl['DeliveryDocument']}", "Shipping",
         f"Picked at {dl['ShippingPoint']}, warehouse {dl['Warehouse']}"),
        (gi["PostingDate"], f"Goods issue {gi['MaterialDocument']}", "Shipping", "Stock out, cost of goods sold posted"),
        (bd["BillingDocumentDate"], f"Invoice {bd['BillingDocument']}", "Billing",
         f"Receivable {money(bd['TotalNetAmount'])}, due {due.isoformat()}"),
        (pay["PostingDate"], f"Payment {pay['AccountingDocument']}", "Accounts receivable", "Receivable cleared"),
    ]
    if not show:
        return steps
    print("Order 1000001, end to end (fictional Calder data)\n")
    for when, doc, team, what in steps:
        print(f"  {when}  {doc:<32} {team:<20} {what}")
    print("\nPricing re-check (condition records in this script vs. the order's net values):")
    for item in so["to_Item"]:
        p = price_item(so["SoldToParty"], so["DistributionChannel"], item["Material"])
        net = round(item["RequestedQuantity"] * p["price"] * (1 - p["discount"]), 2)
        flag = "OK" if abs(net - item["NetAmount"]) < 0.01 else "DIFFERS"
        print(f"  item {item['SalesOrderItem']} {item['Material']}: {p['found']} -> {money(net)}  {flag}")
    print("\nCycle times:")
    print(f"  order to goods issue  {(issued - ordered).days:>3} days")
    print(f"  invoice to cash       {(paid - billed).days:>3} days (due after {(due - billed).days})")
    print(f"  order to cash         {(paid - ordered).days:>3} days")
    return steps


# ----------------------------------------------- part 2: what is stuck, and why
def evaluate(pack: dict) -> list:
    """Run pricing, incompleteness, availability, credit and block checks on every open order."""
    atp, rows = Availability(), []
    exposure = {c: sum(a for cust, a in OPEN_RECEIVABLES.values() if cust == c) for c in CREDIT_LIMIT}
    for order, cust, dc, div, terms, wanted, items, dblock, bblock, shipped in ORDERS:
        checks, value, at_risk = [], 0.0, 0.0
        for item, mat, qty in items:
            p = price_item(cust, dc, mat)
            if p["price"] is None:
                checks.append(("price missing", f"item {item} {mat}: {p['found']}", 0.0))
                continue
            net = round(qty * p["price"] * (1 - p["discount"]), 2)
            value += net
            checks.append(("price", f"item {item} {mat}: {p['found']}, net {money(net)}", None))
            if shipped:
                continue
            lines = atp.check(mat, qty, wanted)
            text = ", ".join(f"{q} on {d.isoformat()}" if d else f"{q} unconfirmed" for d, q in lines)
            short = sum(q for d, q in lines if d != wanted)
            if short:
                at_risk_item = round(short * net / qty, 2)
                checks.append(("partly confirmed", f"item {item}: {text}", at_risk_item))
            else:
                checks.append(("availability", f"item {item}: {text}", None))
        if not terms:
            checks.append(("billing data incomplete", "payment terms missing: can deliver, cannot bill", value))
        if value and not shipped:
            after = exposure[cust] + value
            if after > CREDIT_LIMIT[cust]:
                checks.append(("credit block", f"exposure {money(after)} > limit {money(CREDIT_LIMIT[cust])}", value))
            else:
                checks.append(("credit", f"exposure {money(after)} within limit {money(CREDIT_LIMIT[cust])}", None))
            exposure[cust] = after
        if dblock:
            checks.append(("delivery block", f"reason {dblock}: {BLOCK_TEXT[dblock]}", value))
        if bblock:
            checks.append(("billing block", f"reason {bblock}: {BLOCK_TEXT[bblock]}", value))
        problems = [c for c in checks if c[2] is not None]
        if problems:
            at_risk = max(c[2] for c in problems)
        rows.append({"order": order, "customer": cust, "wanted": wanted, "value": value,
                     "checks": checks, "problems": problems, "at_risk": at_risk})
    # Receivables side: a short payment left a residual item that is now a dispute.
    rows.append({"order": "invoice 90000002", "customer": "10000002", "wanted": date(2026, 10, 15),
                 "value": 1150.00, "checks": [], "at_risk": 1150.00,
                 "problems": [("short payment", "paid 1,150.00 less than invoiced; residual item open", 1150.00)]})
    return rows


def worklist(rows: list) -> list:
    """Stuck items, most urgent first: past or nearest requested date, then most cash."""
    stuck = [r for r in rows if r["problems"]]
    return sorted(stuck, key=lambda r: (r["wanted"], -r["at_risk"]))


def print_worklist(pack: dict, rows: list) -> None:
    who = names(pack)
    items = worklist(rows)
    print(f"\nOrder-to-cash worklist as of {AS_OF.isoformat()} (fictional Calder data)\n")
    print(f"  {'#':<2} {'Document':<17} {'Customer':<23} {'Wanted':<10} {'Problem':<24} {'At risk':>10}  Owner")
    for n, r in enumerate(items, start=1):
        first = r["problems"][0]
        late = " (past)" if r["wanted"] < AS_OF else ""
        print(f"  {n:<2} {r['order']:<17} {who[r['customer']][:23]:<23} {r['wanted'].isoformat():<10}"
              f" {first[0]:<24} {risk_text(first[0], r['at_risk']):>10}  {OWNER[first[0]]}{late}")
    total = sum(r["at_risk"] for r in items)
    print(f"\n  {len(items)} items stuck, {money(total)} USD of priced value delayed. Order 1000001 is complete.")


def show_order(pack: dict, rows: list, number: str) -> int:
    row = next((r for r in rows if r["order"] == number), None)
    if not row:
        print(f"Order {number} is not in the scenario. Try one of: "
              + ", ".join(r["order"] for r in rows if r["order"].isdigit()))
        return 1
    print(f"Order {number} for {names(pack)[row['customer']]}, wanted {row['wanted'].isoformat()},"
          f" value {money(row['value'])} USD\n")
    for kind, text, risk in row["checks"]:
        mark = "STUCK" if risk is not None else "OK   "
        print(f"  {mark} {kind:<24} {text}")
    if row["problems"]:
        print(f"\n  Next: {OWNER[row['problems'][0][0]]}.")
    return 0


# --------------------------------------------------------------- deliverable
def write_markdown(pack: dict, steps: list, rows: list) -> None:
    who = names(pack)
    fence = "`" * 3
    out = ["# Calder order to cash: one order traced, open orders triaged", "",
           "All data is fictional (Calder Pumpworks sample pack and the scenario in order_to_cash.py).", "",
           fence + "mermaid", "flowchart LR",
           "  Q[Quotation] --> SO[Sales order<br/>price, ATP, credit] --> DL[Delivery<br/>pick, pack]",
           "  DL --> GI[Goods issue<br/>cost posted] --> BD[Invoice<br/>receivable posted] --> PAY[Payment<br/>cleared]",
           "  BD -.-> DSP[Dispute<br/>if short paid]", fence, "",
           "## Order 1000001", "", "| Date | Document | Team | What happened |", "| --- | --- | --- | --- |"]
    out += [f"| {w} | {d} | {t} | {x} |" for w, d, t, x in steps]
    out += ["", f"## Worklist as of {AS_OF.isoformat()}", "",
            "| # | Document | Customer | Wanted | Problem | Detail | At risk (USD) | Owner |",
            "| --- | --- | --- | --- | --- | --- | ---: | --- |"]
    for n, r in enumerate(worklist(rows), start=1):
        kind, text, _ = r["problems"][0]
        out.append(f"| {n} | {r['order']} | {who[r['customer']]} | {r['wanted'].isoformat()} | {kind} |"
                   f" {text} | {risk_text(kind, r['at_risk'])} | {OWNER[kind]} |")
    out += ["", "Synthetic numbers: they show the method, not results from an SAP system.", ""]
    OUT.write_text("\n".join(out), encoding="utf-8")
    print(f"wrote unit1b/{OUT.name}")


# ------------------------------------------------------------------ sandbox
def read_env_key(name: str) -> str:
    if os.environ.get(name):
        return os.environ[name]
    env = Path(".env")
    if env.exists():
        for line in env.read_text(encoding="utf-8").splitlines():
            key, sep, value = line.partition("=")
            if sep and key.strip() == name:
                return value.strip().strip('"').strip("'")
    return ""


def sandbox() -> int:
    """Count orders with blocks in SAP's demo data. Codes are printed as they come,
    because their meanings are configuration: read them, don't assume them."""
    key = read_env_key("SAP_API_KEY")
    if not key:
        sys.exit("SAP_API_KEY is not set. Add it to .env (course setup topic), or run without --sandbox.")
    fields = ("SalesOrder,SalesOrderType,TotalNetAmount,TransactionCurrency,OverallSDProcessStatus,"
              "TotalCreditCheckStatus,DeliveryBlockReason,HeaderBillingBlockReason")
    url = SANDBOX + f"API_SALES_ORDER_SRV/A_SalesOrder?$top=50&$select={fields}&$format=json"
    request = urllib.request.Request(url, headers={"APIKey": key, "Accept": "application/json"})
    try:
        with urllib.request.urlopen(request, timeout=30) as response:
            orders = json.load(response)["d"]["results"]
    except urllib.error.HTTPError as err:
        sys.exit(f"Sandbox answered HTTP {err.code}. 401 means the key is wrong; see the table below.")
    except urllib.error.URLError as err:
        sys.exit(f"Cannot reach the sandbox: {err.reason}. Check your network or proxy, or use the pack.")
    print(f"SAP sandbox: {len(orders)} sales orders read (SAP demo data, not Calder's)\n")
    for field in ("OverallSDProcessStatus", "TotalCreditCheckStatus"):
        counts = {}
        for so in orders:
            counts[so.get(field) or "(empty)"] = counts.get(so.get(field) or "(empty)", 0) + 1
        print(f"  {field}: " + ", ".join(f"'{k}' x{v}" for k, v in sorted(counts.items())))
    for field in ("DeliveryBlockReason", "HeaderBillingBlockReason"):
        blocked = [so["SalesOrder"] for so in orders if so.get(field)]
        print(f"  {field} set on {len(blocked)} orders" + (f", e.g. {', '.join(blocked[:3])}" if blocked else ""))
    return 0


def main() -> int:
    parser = argparse.ArgumentParser(description="Trace and triage Calder's order to cash.")
    parser.add_argument("--order", help="show every check for one order, e.g. 1000004")
    parser.add_argument("--write", action="store_true", help="write the worklist to unit1b/calder_o2c_worklist.md")
    parser.add_argument("--sandbox", action="store_true", help="count blocked orders in SAP's sandbox")
    args = parser.parse_args()
    if args.sandbox:
        return sandbox()
    pack = load_pack()
    rows = evaluate(pack)
    if args.order:
        return show_order(pack, rows, args.order)
    if args.write:
        write_markdown(pack, trace_1000001(pack, show=False), rows)
        return 0
    trace_1000001(pack)
    print_worklist(pack, rows)
    return 0


if __name__ == "__main__":
    sys.exit(main())

Step 4: Trace order 1000001 and see the worklist

  1. Run the script:

    • Windows (PowerShell):

      python unit1b\order_to_cash.py
    • macOS / Linux:

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

Order 1000001, end to end (fictional Calder data)

  2026-09-08  Quotation 20000001 (scenario)    Sales                Prices offered, valid to 2026-09-30
  2026-09-14  Sales order 1000001              Sales                Net 29,100.00 USD, terms NT30
  2026-09-25  Outbound delivery 80000001       Shipping             Picked at RK01, warehouse RKDC
  2026-09-25  Goods issue 4900000001           Shipping             Stock out, cost of goods sold posted
  2026-09-26  Invoice 90000001                 Billing              Receivable 29,100.00, due 2026-10-26
  2026-10-20  Payment 1400000001               Accounts receivable  Receivable cleared

Pricing re-check (condition records in this script vs. the order's net values):
  item 10 CP-ES200: PRICE from customer/material 10000001/CP-ES200 -> 16,800.00  OK
  item 20 CP-ES300: PRICE from customer/material 10000001/CP-ES300 -> 12,300.00  OK

Cycle times:
  order to goods issue   11 days
  invoice to cash        24 days (due after 30)
  order to cash          36 days

Order-to-cash worklist as of 2026-10-23 (fictional Calder data)

  #  Document          Customer                Wanted     Problem                     At risk  Owner
  1  invoice 90000002  Hallam Fluid Systems    2026-10-15 short payment              1,150.00  Accounts receivable (dispute case) (past)
  2  1000006           Brennock Water Utility  2026-10-20 billing block              4,200.00  Billing clerk, with sales (past)
  3  1000004           Brennock Water Utility  2026-10-27 partly confirmed          24,600.00  Order fulfillment, with production planning
  4  1000005           Hallam Fluid Systems    2026-10-28 price missing              unpriced  Pricing team
  5  1000002           Hallam Fluid Systems    2026-10-30 credit block              24,288.00  Credit analyst (credit decisions)
  6  1000003           Brennock Water Utility  2026-11-02 billing data incomplete   12,300.00  Sales order team, with master data
  7  1000007           Brennock Water Utility  2026-11-09 delivery block             8,400.00  Customer service

  7 items stuck, 74,938.00 USD of priced value delayed. Order 1000001 is complete.

How to read it:

  • The trace rebuilds the chain from the pack and adds the scenario's quotation. The pricing re-check runs the script's access sequence for each item and finds Brennock's contract price first, matching the order's net values.
  • Cycle times are the process's own KPIs. Brennock paid 24 days after the invoice, 6 days before the due date, and the whole order took 36 days from order to cash.
  • The worklist is sorted by the date the customer wanted, then by value. Past dates are marked (past). Each line names one owner, because a blocked order nobody owns stays blocked.

Step 5: Look at one order's gates

  1. Show every check for order 1000004:

    • Windows (PowerShell):

      python unit1b\order_to_cash.py --order 1000004
    • macOS / Linux:

      python unit1b/order_to_cash.py --order 1000004
  2. You should see:

Order 1000004 for Brennock Water Utility, wanted 2026-10-27, value 30,750.00 USD

  OK    price                    item 10 CP-ES300: PRICE from customer/material 10000001/CP-ES300, net 30,750.00
  STUCK partly confirmed         item 10: 1 on 2026-10-27, 4 on 2026-11-06
  OK    credit                   exposure 43,050.00 within limit 250,000.00

  Next: Order fulfillment, with production planning.

Order 1000003 was entered first and needs two ES-300 pumps on 2026-11-02. Three are in stock, so only one is free on 2026-10-27 for 1000004: the check looks ahead and protects the earlier order's confirmation. The other four wait for the production receipt on 2026-11-06. That is cumulative ATP in miniature, and the reason a planner, not a sales clerk, owns this line.

  1. Now try --order 1000002. The credit gate fails:
Order 1000002 for Hallam Fluid Systems, wanted 2026-10-30, value 24,288.00 USD

  OK    price                    item 10 CP-ES200: PRICE from channel/material 20/CP-ES200, net 24,288.00
  OK    availability             item 10: 6 on 2026-10-30
  STUCK credit block             exposure 45,678.00 > limit 40,000.00

  Next: Credit analyst (credit decisions).

Hallam's open receivables (21,390.00 USD, including the disputed residual item) plus the new order exceed its 40,000 USD limit. Availability is fine, so the analyst's decision is the only thing holding the pumps. Try --order 1000003, 1000005, 1000006 and 1000007 too.

Step 6: Write the worklist file

  1. Run:

    • Windows (PowerShell):

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

      python unit1b/order_to_cash.py --write
  2. You should see wrote unit1b/calder_o2c_worklist.md.

  3. Open the file in VS Code and press Ctrl+Shift+V (Windows, Linux) or Cmd+Shift+V (macOS) to preview it. You should see a process diagram, the trace of order 1000001 and a seven-line worklist with an owner on every line.

Step 7 (optional): Count blocked orders in 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\order_to_cash.py --sandbox
    • macOS / Linux:

      python unit1b/order_to_cash.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> sales orders read (SAP demo data, not Calder's)

  OverallSDProcessStatus: '<code>' x<count>, '<code>' x<count>
  TotalCreditCheckStatus: '(empty)' x<count>, '<code>' x<count>
  DeliveryBlockReason set on <count> orders, e.g. <number>, <number>
  HeaderBillingBlockReason set on <count> orders

The script prints the status codes as they come and doesn't translate them, because their meanings are configuration. A valid "empty" result is set on 0 orders: the first 50 demo orders simply have no blocks. The sandbox is read-only, so you can't release or change anything there.

Step 8: Save your work in Git

git add unit1b/order_to_cash.py unit1b/calder_o2c_worklist.md
git commit -m "Unit 1B: order-to-cash trace and worklist for Calder"

What each part of the code does

Part What it does
PRICE_RECORDS, DISCOUNT_RECORDS, price_item() A small condition technique: try customer/material first, then channel/material, stop at the first record, then apply the customer's discount
STOCK, RECEIPTS, Availability Cumulative ATP with look-ahead: what is free on a date without taking stock an earlier order needs later; returns schedule lines
CREDIT_LIMIT, OPEN_RECEIVABLES Calder's simplified credit data: limit per customer; exposure starts with open receivables and grows with each open order
ORDERS Six open orders, each with the data the gates need, including delivery and billing block codes
trace_1000001() Rebuilds the pack's order from quotation to payment, re-checks prices and computes cycle times
evaluate() Runs the gates on every open order and records what is stuck and how much value it holds
worklist(), print_worklist() Sorts stuck items by wanted date, then value, and names one owner per item
--order, --write Show one order's gates; write the worklist file
sandbox() Reads up to 50 sandbox orders with $select and counts statuses and block reasons, without interpreting codes

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
KeyError naming a customer or material The pack or the scenario was edited Run Step 2 again and copy the script again
Your numbers differ from the ones shown Prices, stock or limits in the script were changed Copy the script again, or compare your edits
Order ... is not in the scenario You asked for an order the script doesn't have Use one of the numbers the message lists
SAP_API_KEY is not set No key in the environment or .env Add it as in the course setup, or skip Step 7
Sandbox answered HTTP 401 The key is wrong or expired Copy the key again from the Business Accelerator Hub
Cannot reach the sandbox No network, or a company proxy blocks it Try another network, or stay with Steps 1 to 6
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), order to cash for CP10 runs inside S/4HANA. Customer orders come in by EDI from equipment makers and distributors, and by PDF from utilities. The CRM holds opportunities and quotes for engineered pumps. Payments arrive as bank statements. Anything that triages or explains reads released interfaces and writes only to a worklist; decisions go back through SAP's own apps.

flowchart LR
  EDI[EDI and PDF<br/>customer orders] --> S4[S/4HANA CP10<br/>orders, ATP, credit,<br/>billing, receivables]
  CRM[CRM<br/>opportunities, quotes] --> S4
  BANK[Bank statements] --> S4
  S4 -->|released APIs, CDS, events| TRI[Triage service<br/>read-only]
  TRI --> WL[Worklist per owner]
  WL -->|person decides in SAP app| S4
  ECC[ECC CP20] -.->|intercompany| S4

Non-functional requirements that decide the design

Requirement Typical target If missed
Order entry response Pricing, ATP and credit check within seconds per order Sales staff work around the system; EDI queues back up
Peak volume Month-end and quarter-end billing runs finish in their batch window Invoices slip into the next period; revenue and DSO suffer
Availability Order entry available in all selling hours, EDI retried on failure Lost or duplicate orders
Credit decision time Blocked orders reach an analyst the same day Pumps wait at the dock; customers escalate
Security Readers see only the sales areas and company codes they are authorized for Prices, margins and credit data exposed through a convenience tool
Auditability Every release, rejection and credit change is logged with who and why Audit findings; no basis for evaluating any AI recommendation

Integration and identity. Customer orders arrive by EDI as IDoc message types such as ORDERS, with ORDRSP for confirmations and INVOIC for invoices; SAP's Business Network training lists them from the buyer's side (SAP Learning). In cloud editions, released OData and SOAP APIs replace much of this. The triage service subscribes to sales order events and reads the order, credit and delivery views. It reads with the requesting user's identity where possible, so SAP's authorizations on sales area and company code apply (see grounding with authorizations). Integration patterns are covered in how SAP systems talk.

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

  • Context: Calder's credit analysts find blocked orders by running a report twice a day. Distributors complain that orders wait a day for release. CP20 runs ECC with the old credit management.
  • Options: (a) credit check at order entry only; (b) at order entry and again at goods issue; (c) at delivery creation only.
  • Decision: (b) for CP10. Order entry catches risk early, while the check at goods issue catches exposure that grew between order and shipment. Blocked orders feed a worklist the same hour.
  • Consequences: Some orders are checked twice, and the analysts need a clear rule for orders released at entry but blocked at goods issue. Group credit across CP10 and CP20 is manual until CP20 migrates.

What a design review will ask

  1. Where are credit checks placed, and what does a customer experience when a check fails at goods issue?
  2. Which ATP features are licensed in your edition, and what happens when a confirmed receipt slips?
  3. Which channels send orders and invoices (EDI, email, portal), and who monitors failed messages?
  4. Can any component release, reject, reprice or rebill without a person's decision?
  5. How will you measure whether triage helped: block age, DSO, on-time delivery?

Where AI fits, and where it doesn't

Step Pattern Fit Note
Order intake from PDF and email Extract Strong SAP lists AI-assisted sales order creation from unstructured purchase orders as GA in S/4HANA Cloud Public Edition, Q1 2026 (SAP News); see Document AI and SAP
EDI orders Plain automation is enough Structured already; monitor failures instead
Pricing Plain automation is enough Weak for AI The condition technique is deterministic and auditable; AI can explain why a record was found
Availability Predict Moderate Predict whether a confirmed receipt will slip; see predicting late deliveries
Blocked-order triage Retrieve and explain Strong Gather exposure, payment history and order facts, recommend; a person releases. The FDE topic uses this example
Billing Plain automation is enough Exceptions (blocks, incompleteness) feed the same triage
Cash application Match Strong SAP Cash Application matches bank items to open invoices (SAP Learning)
Disputes Classify and retrieve Strong Classify the reason and gather the invoice, delivery and price evidence; SAP's Dispute Resolution Agent was a beta release for S/4HANA Cloud Public Edition in Q1 2026 (SAP News); as of October 2026, SAP lists it in the Accounts Receivable Assistant, with general availability rolling out in phases (SAP News)
Collections Predict Moderate Payment-delay risk ranks the collector's worklist; Calder's data is too small to train on

The line is the same at every step: AI reads, explains, ranks and suggests; a person releases credit, changes prices, cancels invoices and writes off amounts. Human approval in AI workflows and agent permissions show how to enforce that in code.

Pitfalls

  • Treating every block as a defect. Teams that measure "blocks per day" push to loosen controls. Measure block age and decision quality instead.
  • Reading statuses as universal codes. Credit, delivery and billing status values and block reasons are configured. Read them from the system.
  • Ignoring the look-ahead in ATP. "There are three in stock" doesn't mean three can be promised; earlier orders may already hold them.
  • One credit check, wrong place. Checking only at order entry misses exposure that grew before shipping; checking only at goods issue surprises the warehouse.
  • Testing the happy path. The costly cases are partial confirmations, short payments and returns. Put them in the test plan.
  • Triage without an owner. A worklist line with no owner is a report, not a process.
  • Assuming ECC and S/4HANA match. Credit management, business partners and output management differ, which matters for Calder's CP20.

Exercise

Turn the worklist into a triage note a Calder team lead could use, and see how one fix exposes the next gate.

  1. Run python unit1b/order_to_cash.py --write (Windows: python unit1b\order_to_cash.py --write). Copy unit1b/calder_o2c_worklist.md to unit1b/calder_o2c_triage.md.

  2. In order_to_cash.py, add this line inside PRICE_RECORDS, just after the comment about channel 30:

        ("channel/material", "30", "CP-SK200"): 62.00,           # aftermarket seal kit price
  3. Run python unit1b/order_to_cash.py --order 1000005. The price gate now passes. Note which gate fails instead and why.

  4. In the copy, add a column Next action to the worklist table. For each line write one sentence: what the owner should check or decide.

  5. Add a column AI could with one of: extract, explain, predict, match or nothing. Use the table under Where AI fits to choose.

  6. Under a heading One fix, next gate, write two lines on what you saw in Step 3.

  7. Under a heading ECC note, write one line on what a credit analyst would need to see for a customer that buys from both CP10 and CP20.

  8. Remove the line you added in Step 2, preview the file, and commit it with Git.

Done when: unit1b/calder_o2c_triage.md has the seven-line worklist with Next action and AI could filled in for every line, a One fix, next gate section that names the credit block on 1000005 and the exposure above Hallam's 40,000.00 USD limit, and an ECC note. Keep it: the capstone at the end of Unit 1B traces one exception across the whole enterprise, and this triage note is its order-to-cash part.

Check yourself

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

1In the script, order 1000004 asks for five ES-300 pumps on 2026-10-27 with three in stock. Why is only one confirmed for that date?

Answer: A. Cumulative ATP looks ahead. Two of the three pumps are promised to an earlier order on a later date, so only one is free now; the rest waits for the receipt on 2026-11-06.

2An order item has no price because no condition record matches. Where do you look first?

Answer: C. The condition technique tries the access sequence's tables in order and stops at the first valid record. A missing price means no table holds a record for this customer, channel and material key.

3You must list credit-blocked sales orders from custom code in an S/4HANA cloud edition. What do you read?

Answer: B. The Cloudification Repository lists I_CreditBlockedSalesDocument as released and marks VBAK as not to be released. Released views survive upgrades; table access in custom code doesn't fit clean core.

4The sandbox shows TotalCreditCheckStatus values you haven't seen before. What should your code do?

Answer: C. Status code values are SAP domain values and can depend on configuration. Hard-coded lists break silently; reading the system's own values keeps triage correct.

5Hallam pays an invoice 1,150 USD short and the difference is posted as a residual item. What is true?

Answer: A. With a residual item, the original invoice is cleared and the difference becomes a new open item, which a dispute case can track. A partial payment would instead leave the invoice open.

6Calder plans credit checks at order entry and again at goods issue. What consequence must the design handle?

Answer: C. Exposure can grow between order and shipment, so the second check can block an order that passed the first. Analysts and the warehouse need a rule for that case.

7What does S/4HANA change for credit management compared with Calder's ECC company CP20?

Answer: D. SAP's simplification guidance states FI-AR-CR is not available in S/4HANA and SAP Credit Management replaces it, with business partners and credit segments. CP20 on ECC still uses the old data.

8Your triage agent finds that order 1000002 is blocked only for credit and Hallam has paid on time for a year. What should it do?

Answer: B. Releasing credit and changing limits are decisions with an audit trail, made by a credit analyst. The agent's job is to put the evidence and a recommendation in front of that person.

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 3 of 11Configure sellingThe whole stage practices this topic, on Pricing procedure steps, Pricing procedure assignment and Credit check settings.
  • Stage 8 of 11Take ordersThe whole stage practices this topic, on Sales orders and Price conditions.
  • Stage 9 of 11Deliver and shipThe whole stage practices this topic.
  • Stage 10 of 11Invoice and collectThe whole stage practices this topic, on Billing and Incoming payments.
  • Stage 1 of 11Found the companyPractice it on Sales organizations and Sales areas.
  • Stage 2 of 11Set up financePractice it on Payment terms and Credit segments.
  • Stage 4 of 11Create master dataPractice it on Price conditions.
  • 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.
  • 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 delivery that won't startFinance, about 6 minutes
  • DrillThe order that can't be pricedSales, about 5 minutes

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