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.

Plan to produce end to end

Follow demand through MRP, production orders, confirmations and settlement in SAP, read the exception messages planners live with, and decide where AI helps.

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

The 60-second version

Plan to produce is everything between "we expect to sell this" and "the finished product is on the shelf, and we know what it really cost". In SAP it runs as a chain: demand, a planning run, planned orders, production orders, components issued, work confirmed, finished goods received, and the order's costs settled.

The heart of it is material requirements planning (MRP). MRP compares what is needed with what is in stock or on the way. Where something is short, it proposes a planned order to make it or a purchase requisition to buy it, dated backward from the need.

Where MRP can't solve a problem, it raises exception messages: this order should come earlier, that one isn't needed, this stock is below the safety level. Reading that list every morning is the planner's daily work.

Three ideas carry this topic:

  • MRP plans with unlimited capacity. It assumes every machine and person is free. Whether the plant can actually build it is a separate check.
  • Exceptions are the interface. A planner doesn't read the whole plan. They read what MRP flagged, so the order and quality of that list decide how good planning is.
  • Production closes in finance. Every order collects its real costs and is credited at the standard cost. The difference is a production variance, and someone has to explain it.

Why it matters to the business

Plan to produce decides whether customers get products on time, how much inventory the company carries, and whether product costs are what finance thinks. Operations watches on-time order completion; finance watches inventory value and production variances.

Here is Monday morning at Calder Pumpworks, the fictional pump maker this unit follows. On 26 October 2026, the MRP results for the Rockford plant show these problems near the top of the list:

What MRP or the capacity check shows What it means Who acts
A production order for four ES-300 pumps finishes 6 November, but they are needed 27 October A customer order will ship late Production planner, with sales
Six more ES-300 pumps should have started last week MRP had to schedule them forward; they arrive two days late Production planner
Two motor orders arrive after the motors are needed, and one isn't even confirmed Assembly will stop for lack of motors Buyer
Motor stock is below its safety level No buffer left for a bad week Buyer and planner
The test bench is loaded at 125% in the week it goes down for maintenance MRP didn't know the bench would be out Production supervisor, with maintenance
A seal kit order for 100 kits isn't needed at all Cash tied up in stock nobody will use soon Buyer

The business decision is not "run MRP more often". It is who reads which messages, in what order, and how fast each reaches the person who can fix it.

How SAP does it

As of October 2026, SAP S/4HANA covers the whole chain as standard. Each step is documented in SAP's training:

  • Demand. Forecasts or manual entries become planned independent requirements (PIRs) for a material in a plant. Forecasts can come from SAP Integrated Business Planning (IBP) or from planning inside S/4HANA (SAP Learning, Public Edition). A planning strategy, such as make-to-stock or make-to-order, says how PIRs and sales orders interact (SAP Learning).
  • MRP. MRP Live runs the planning inside the SAP HANA database. It is started in the Schedule MRP Runs app or with transaction MD01N, and planners review results in Monitor Stock/Requirements List (MD04) (SAP Learning).
  • Production orders. Planned orders are converted into production orders close to production, one by one or in bulk (SAP Learning). Release, after availability and capacity checks, allows goods issues and confirmations (SAP Learning).
  • Shop floor. Operators confirm yield, scrap and time, which charges the order with actual costs (SAP Learning, Public Edition). A manufacturing execution system such as SAP Digital Manufacturing can take this over and report back (SAP Learning).
  • Costing. At period end, controlling calculates work in process for open orders and variances for finished ones, then settles them (SAP Learning).

For planning beyond what MRP does, SAP offers production planning and detailed scheduling (PP/DS), embedded in S/4HANA, for finite scheduling of bottleneck resources (SAP Learning), and SAP IBP for network-wide supply planning (SAP Learning).

For AI, SAP's Sapphire 2026 innovation guide lists a Planning Assistant in Joule, with agents for exception management, demand fulfillment and lead-time analysis, as planned for general availability in Q4 2026, and a Manufacturing Assistant as planned for Q2 2026 (SAP). Both dates are SAP's plans as published for Sapphire in 2026, and the Q2 date has passed since. Check their current status and licensing before you plan on them. Where AI creates value in S/4HANA places them in the wider picture.

The process on one page

flowchart LR
  D[Demand<br/>PIRs, sales orders] --> MRP[MRP run]
  MRP --> PL[Planned order]
  MRP --> PR[Purchase<br/>requisition]
  MRP -.-> EX[Exception<br/>messages]
  PL --> PO[Production order<br/>release]
  PO --> GI[Components<br/>staged, issued]
  GI --> CF[Confirmations]
  CF --> GR[Goods receipt<br/>inspection?]
  GR --> ST[Settlement<br/>variances]
Step Team Document created Posts to finance?
Demand is planned Demand planning, sales Planned independent requirements; sales orders No
MRP runs Planning (MRP controller) Planned orders, purchase requisitions, exception messages No
Order is created Production planner Production order (or process order), reservations No
Order is released Production supervisor Status change, shop papers No
Components go to the line Warehouse Goods issue (material document) Yes: inventory to the order
Work is reported Operators Confirmations (yield, scrap, time) Yes: activity costs to the order
Product is received Operators, warehouse Goods receipt; inspection lot if required Yes: finished goods at standard, order credited
Period closes Cost accounting Work in process, variances, settlement Yes: variance to price differences

How logistics steps become accounting entries is the subject of Document flow and how logistics becomes finance.

Questions to ask

  1. How many MRP exception messages do planners see each morning, how many do they act on, and how are they sorted?
  2. Which messages touch a customer order, and how fast does sales hear about a late one?
  3. Who owns safety stock, lot sizes and lead times, and when were they last reviewed against reality?
  4. Where is capacity checked, and how do plant maintenance outages reach that check?
  5. How large are production variances each month, and can someone explain the top five within a day?
  6. If an AI tool proposes rescheduling or cancelling an order, who approves it, and is that logged?

Common misconceptions

  • "MRP knows what the plant can build." It doesn't. MRP and lead time scheduling assume infinite capacity. Overloads show up only in a capacity check or an advanced planning tool.
  • "An exception message is an error." It is a suggestion. "Reschedule in" means a receipt is too late; a person decides whether to move it.
  • "The standard cost is the real cost." It is the planned cost. The variance on each order is the difference, and it is where scrap, rework and slow lines become visible in money.
  • "An AI agent should reschedule orders." It can rank messages, explain them and propose a fix. Moving a production order or cancelling a supplier's order is a person's decision.

Key terms

All course terms, A to Z, in the glossary

  • Planned independent requirement (PIR): expected demand for a material in a plant, from a forecast or entered by hand.
  • Planning strategy: the rule for how PIRs and sales orders work together, for example make-to-stock or make-to-order.
  • MRP (material requirements planning): the run that compares requirements with stock and receipts and proposes what to make or buy.
  • Planned order: MRP's proposal to make something; it becomes a production order.
  • Exception message: MRP's flag on a situation it can't solve, such as reschedule in, reschedule out or cancel.
  • Production order: the order to make a quantity of a product, with components, operations and costs.
  • Confirmation: the shop floor's report of yield, scrap and time for an operation.
  • Work in process (WIP): the costs on orders that aren't finished at period end.
  • Production variance: the difference between an order's actual costs and the standard value of what it delivered.

Check yourself

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

1Calder's MRP proposes six ES-300 pumps for a week when the assembly line is already full. Why didn't MRP see that?

Answer: B. MRP and lead time scheduling assume infinite capacity. Overloads show up when a planner or supervisor runs a capacity check or uses an advanced planning tool.

2A planner sees "reschedule in" on a production order. What is MRP telling them?

Answer: C. Reschedule in means a receipt is later than its requirement. MRP flags it and leaves the decision to a person, because moving an order affects capacity and components.

3The planners at a plant get 900 exception messages each morning and fix about a hundred. What should the plant improve first?

Answer: A. Planners can't read everything, so the order of the list decides what gets fixed. Ranking by customer impact and cost puts the messages that matter in front of the person who can act.

4What does a production variance on a finished order tell finance?

Answer: A. The order collects actual costs and is credited at standard for what it delivered. The difference is the variance, which shows scrap, extra hours or price changes in money.

5Calder's test bench goes down for maintenance in a week full of test work. What link would have caught it earlier?

Answer: A. MRP doesn't know about the outage. When maintenance plans reduce the work center's available capacity, the capacity check shows the overload before the week starts.

6A vendor pitches an agent that moves production orders and cancels supplier orders on its own. What should you ask for?

Answer: B. Rescheduling an order or cancelling a supplier's order affects customers, suppliers and capacity. An agent can rank and explain; a planner or buyer decides and the decision is logged.

7Calder wants to schedule its bottleneck test bench to the minute, with finite capacity. Which SAP option fits?

Answer: D. PP/DS plans with exact times and can treat resources as finite. MRP Live assumes infinite capacity, and SAP IBP's supply planning works at the network level, with detailed scheduling done in S/4HANA.
Deep layer · 40 min read

Mental model

Think of plan to produce as a promise that is checked twice: once for material, once for money.

  • Material. MRP turns demand into a time-phased list of receipts: make this many by this date, buy that many by that date. Each receipt is checked against the date it is needed. Where it is too late, too early or not needed at all, MRP raises an exception message. MRP checks material only. Capacity is a second check, and the shop floor's confirmations are the third.
  • Money. Each production order is a small cost account. Goods issues and confirmations debit it with actual costs. The goods receipt credits it at the standard price. Whatever is left at period end is either work in process (the order isn't finished) or a variance (it is), and settlement moves it to finance.

Everything in this topic, every worklist and every AI idea, reads one of those two checks. An exception message says the material promise is at risk. A variance says the money promise was broken, and by how much.

Business objects and documents

Before you start: this topic builds on The SAP data model for AI engineers, How an SAP enterprise is wired (plants), Master data that every module depends on (BOMs, routings, work centers, production versions, MRP data) and Document flow and how logistics becomes finance. It doesn't repeat them. Order to cash end to end supplies the demand and Procure to pay end to end the bought components.

Demand: planned independent requirements and planning strategies

A planned independent requirement (PIR) is expected demand for a material in a plant, from a forecast or entered by hand. It can trigger production before any sales order exists. Forecasts can be released from SAP IBP or from planning in S/4HANA and transferred to MRP as PIRs. Sales orders and stock transfer requirements from other sites are the other independent requirements (SAP Learning, Public Edition).

The planning strategy decides how PIRs and sales orders interact. SAP's training lists, among others: 10 make-to-stock with net requirements planning, 11 gross requirements planning, 20 make-to-order, 40 planning with final assembly, 50 planning without final assembly and 70 planning with assemblies. It also says what reduces the PIRs for each: for 10 and 40, goods issues; for 11, goods receipts (SAP Learning). Strategy 40 uses consumption: incoming sales orders consume the PIRs, so the forecast and the orders aren't counted twice. In make-to-order strategies, production starts only when the sales order arrives (SAP Learning).

At Calder (fictional), standard pumps are planned with final assembly: monthly PIRs for ES-200 and ES-300 at plant CP11, consumed by the month's sales orders. Engineered pumps are made to order. Consumption periods and requirement types are configuration; the script in this topic uses a simplified "same month" rule.

The MRP run

MRP does three things for each material: it explodes the BOM and schedules backward from the requirement date, it calculates net requirements (requirements against stock and receipts), and it creates procurement proposals for what is short (SAP Learning). For in-house production it creates planned orders, which are later converted to production orders. For bought materials, MRP Live creates schedule lines when a valid scheduling agreement exists and purchase requisitions otherwise (SAP Learning).

Scheduling works backward. From the requirement date, MRP subtracts the goods receipt processing time to get the order's finish date, then the in-house production time to get its start date. If the start date falls in the past, the system switches to forward scheduling (SAP Help, R/3). That is why a late proposal arrives late, and why it comes with a message.

MRP Live runs the calculation for receipts, requirements and proposals in one database procedure. It reads the planning file, which lists materials with changes since the last run, and plans by low-level code, finished products first. Materials it can't handle go to classic MRP automatically. MRP Live creates no MRP lists; planners read Monitor Stock/Requirements List (MD04) instead. Planning Mode controls what happens to non-fixed proposals from the last run; fixed ones stay as they are (SAP Learning).

Exception messages

When MRP finds a situation it can't solve, it writes an exception message on the element. SAP's knowledge base names the rescheduling messages: 10 reschedule in, 15 reschedule out, 20 cancel process, 25 excess stock, 26 excess in an individual segment and 30 plan process according to reschedule (SAP KBA 1698291). SAP's older help lists others with the same numbers, including 05 opening date in the past, 06 start date in the past, 07 finish date in the past, 50 no BOM exists and 96 stock below safety stock (SAP Help).

Planners work the messages in Manage Material Coverage and Monitor Material Coverage - Net and Individual Segments, or in MD04. Situation handling can push some of them to a team: the template SAP_PP_MRP_MATERIAL_EXCEPTION covers cancel-process and excess-stock situations and opens Manage Material Coverage (SAP Community, SAP S/4HANA RIG team).

From planned order to production or process order

A planned order is internal: the planner can change, reschedule or delete it. Conversion creates the production order. It can be done one at a time, collectively by plant, MRP controller or opening date, or partially, when one planned order becomes several production orders. On conversion, the planned order is deleted and replaced, and the components' dependent requirements become reservations. The planned order's opening date says when to convert (SAP Learning).

Level Holds Calder example (fictional)
Header Material, plant, quantity, order type, basic dates, production version, status, MRP controller, production supervisor Order 1100021: 4 × CP-ES300 at CP11, 28 October to 5 November, released
Item What the order delivers and to which storage location 4 pumps to finished-goods stock
Operations Routing steps at work centers, with standard times 0010 assemble at ASSY1 (2.0 h each), 0020 test at TEST1 (0.75 h each)
Components Reserved materials, quantities, requirement dates, backflush flag 4 × CP-MTR15
Costs Planned costs from the cost estimate; actual costs as they post Planned 16,400.00 USD for 4 pumps

In SAP's model of the Production Order API, the header carries status flags such as OrderIsReleased, OrderIsPartiallyConfirmed, OrderIsDelivered, OrderIsTechnicallyCompleted and SettlementRuleIsCreated, plus MfgOrderPlannedStartDate, MfgOrderPlannedEndDate and MfgOrderConfirmedYieldQty. Components carry RequiredQuantity, WithdrawnQuantity, MatlCompIsMarkedForBackflush, ReservationIsFinallyIssued and GoodsMovementType (SAP Cloud SDK model, 2022).

Process industries use process orders with master recipes instead of routings; SAP's manufacturing execution and warehouse integrations support both order types (SAP Learning). Calder makes discrete products, so this topic uses production orders.

Release and the availability check

Release is a status change from created to released. Printing shop papers, goods issues and confirmations all need it. Release usually follows a component availability check and a capacity evaluation; if components are short, the order can't run until more are procured or taken from a less important order. Orders can be released one at a time, operation by operation, or collectively in Manage Production Orders (F2336), and released orders can be downloaded to a manufacturing execution system (SAP Learning).

Capacity

MRP and lead time scheduling of planned and production orders are infinite: they respect non-working time but not other orders' load, which can cause overloads. Capacity evaluation compares a work center's available capacity with the operations' requirements; capacity leveling schedules finitely into free periods. SAP's tools include Manage Work Center Capacity, which replaces the classic CM* transactions, the Capacity Scheduling Table and the Capacity Scheduling Board. Dispatching an operation firms the planned order or gives the production order operation the status DSPT, so lead time scheduling no longer moves it (SAP Learning).

Component staging and goods issue

At order creation, the system reserves each component. Components are issued manually, for example in Post Goods Movement (MIGO) or Pick Components for Production Orders (CO27), or by backflush when the operation is confirmed, for low-value parts and flow lines. Each issue reduces the reservation, charges the component's cost to the order and creates a material document (SAP Learning).

With extended warehouse management (EWM), stock moves from the warehouse to a production supply area first. SAP describes staging methods for pick parts, release order parts and crate parts, and two integration models: delivery-based, or the advanced model with a production material request that covers staging and consumption in one document (SAP Learning). Calder's Rockford distribution center runs EWM; Warehouse and transportation execution covers it.

Confirmations

Operators confirm yield, scrap and rework per operation, with times for setup, machine and labor. A partial confirmation reports progress; a final one closes the operation. The control key of each operation says whether it must, can or can't be confirmed, and whether it is a milestone. A confirmation updates the status to partially confirmed (PCNF) or confirmed (CNF), records actual dates, charges activity costs to the order from the work center's data, backflushes components and, if configured, posts the goods receipt. Apps include Confirm Production Order and Confirm Production Order Operation (SAP Learning, Public Edition).

Goods receipt and quality inspection

The goods receipt adds the finished quantity to stock, sets the order to partially delivered or delivered and credits the order. It is posted manually in Post Goods Movement (MIGO) or automatically with a confirmation (SAP Learning).

If the material has an inspection type for lot origin 04, the goods receipt creates an inspection lot; an early variant creates it at release, so inspection can run alongside production. With "post to inspection stock", the goods wait in quality inspection until the usage decision moves them to unrestricted or blocked stock (SAP Learning). At Calder, ES-300 pumps get a final inspection; ES-200 pumps don't.

Period end: work in process, variances, settlement

For an order without the delivered or technically completed status, the period-end run calculates work in process (WIP); with no goods receipt yet, WIP equals the actual costs to date. For a delivered or technically completed order, it calculates variances instead. SAP's training groups them on the input side as input price, input quantity, resource usage and remaining variance, and on the output side as lot size, mixed price, output price and remaining variance (SAP Learning). SAP's training also refers to scrap variances, which depend on a scrap indicator on the variance key (SAP Learning).

Settlement clears the order. For a material with standard price control, it debits price differences and credits the inventory change account, cancels WIP from earlier periods, updates the material ledger and can pass variances by category to margin analysis. A price difference splitting profile maps variance categories to G/L accounts, so the income statement shows why production cost more or less (SAP Learning).

Document flow and postings

flowchart LR
  PIR[PIR ES-200<br/>Nov 20] --> PLO[Planned order<br/>0000050011]
  PLO --> PRO[Production order<br/>1100015]
  PRO --> GI[Goods issue<br/>components]
  GI --> J1[Journal<br/>inventory to order]
  PRO --> CF[Confirmations<br/>ASSY1, TEST1]
  CF --> J2[Activity costs<br/>to order]
  PRO --> GR[Goods receipt<br/>10 pumps]
  GR --> J3[Journal<br/>FG at standard,<br/>order credited]
  J3 --> SET[Settlement<br/>variance]
Step Posts to finance Why
PIR, MRP run, planned order, conversion, release Nothing Plans and commitments
Goods issue of components Component inventory leaves stock and is charged to the order The material is used up
Confirmation Activity cost (hours × rate) charged to the order from the work center's cost center Labor and machine time were spent
Overhead (period end) Overhead charged to the order Indirect costs are allocated
Goods receipt Finished goods at standard price; the order is credited The product exists and has a planned value
Usage decision A stock move (inspection to unrestricted or blocked) Same value, different stock type
WIP calculation (open orders) WIP to the balance sheet Costs belong to unfinished goods
Settlement (finished orders) Variance to price differences; WIP cancelled The order's balance is cleared

At Calder (fictional), order 1100015 for 10 ES-200 pumps collected 28,888.00 USD of actual costs and was credited 28,500.00 USD at the 2,850.00 standard price. Settlement posts the 388.00 USD variance to price differences: 38.00 for one extra seal kit and 350.00 for 2.5 extra assembly hours. You compute this in Step 6.

Neighbors

Neighbor Gives plan to produce Takes from plan to produce When the link fails
Sales (order to cash) Sales orders, requested dates Confirmed dates, receipts the availability check can promise Orders promised on supply that doesn't exist; late customers
Demand planning (S/4HANA or SAP IBP) Forecasts released as PIRs Actuals for the next forecast Wrong PIRs build the wrong pumps
Procurement (procure to pay) Purchase orders, confirmed delivery dates Purchase requisitions with dates Components arrive after the line needs them
Inventory and warehouse Stock, staging to the production supply area Reservations, goods receipts Orders released without parts at the line
Quality management Usage decisions Inspection lots from goods receipts Finished goods stuck in inspection; stock looks short
Plant maintenance Planned outages, equipment status Machine hours, breakdown reports Capacity planned on a machine that is down
Controlling Cost estimates, activity rates Actual costs, WIP, variances Unexplained variances; wrong inventory value
Shop floor (MES) Confirmations, goods movements Released orders, BOMs, routings Orders confirmed twice or never; stale status
Intercompany (CP20 to CP10) Machined impellers Requirements for impellers Rockford plans on impellers Augsburg can't make in time

Where it breaks

Symptom Usual cause Who resolves it What it costs Calder (scenario)
Order 1100021 gets message 10 Four ES-300 needed 27 October finish 6 November Production planner, with sales Brennock's order 1000004 ships late
A new planned order gets message 06 Demand arrived inside the production lead time Production planner Six ES-300 pumps arrive 4 November instead of 2 November
PO 4500000002 and 4500000010 get message 10 Motors are due after the line needs them; one PO is unconfirmed Buyer Order 1100019 can't start on 3 November
Motor stock gets message 96 Five motors in stock against a safety stock of six Buyer, planner No buffer for a late delivery
Test bench at 125% in the week of 9 November MRP ignores capacity; a maintenance order takes the bench out for three days Production supervisor, maintenance planner Order 1100019's testing slips
PO 4500000009 gets message 20 100 seal kits not needed in the horizon Buyer 5,200.00 USD of stock bought too early
Order 1100016 leaves a 4,280.00 USD variance One pump scrapped at the pressure test Production engineer, cost accountant The scrap shows in product margin

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), which also marks the classic tables AFKO, AFPO, AFVC, AFRU, RESB, MKAL, CRHD and MARC as not to be released and names a successor for each, for example I_ManufacturingOrder for AFKO and I_MfgOrderConfirmation for AFRU (Cloudification Repository). The OData API names and fields come from SAP's generated API models, published in 2022 (SAP Cloud SDK model, 2022).

Step OData API (A2X) Released CDS views What to read
Demand API_PLND_INDEP_RQMT_SRV I_ActivePlndIndepRqmt, I_ActivePlndIndepRqmtItem PlannedQuantity, WithdrawalQuantity, PlndIndepRqmtIsActive, PlndIndepRqmtVersion
MRP data and results I_ProductSupplyPlanning, I_MRPArea, I_SupplyDemandItemTP, I_MRPExceptionMessage MRP settings, supply and demand elements, the message catalog
Planned order API_PLANNED_ORDERS I_PlannedOrder, I_PlannedOrderComponent, I_PlannedOrderCapacity PlannedOrderIsFirm, PlannedOrderIsConvertible, PlannedOrderOpeningDate, ProductionVersion
Production order API_PRODUCTION_ORDER_2_SRV (functions ConvertPlndOrder, ReleaseOrder, DeliveryCompletedOrder, TechlyCmpltOrder) I_ManufacturingOrder, I_ManufacturingOrderItem, I_ManufacturingOrderOperation, I_MfgOrderWithStatus Status flags, dates, MfgOrderConfirmedYieldQty
Components API_PRODUCTION_ORDER_2_SRV (A_ProductionOrderComponent_3) I_ReservationDocumentItem, I_MfgOrderOperationComponent RequiredQuantity, WithdrawnQuantity, ReservationIsFinallyIssued
Capacity I_WorkCenter, I_WorkCenterCapacity, I_CapacityRequirement, I_ProdnOrderOperationCapacity Available capacity and load per work center
Confirmation API_PROD_ORDER_CONFIRMATION_2_SRV I_MfgOrderConfirmation ConfirmationYieldQuantity, ConfirmationScrapQuantity, VarianceReasonCode, IsFinalConfirmation
Goods movements API_MATERIAL_DOCUMENT_SRV I_MaterialDocumentItem_2, I_MfgOrderDocdGoodsMovement Issues and receipts per order
Inspection API_INSPECTIONLOT_SRV I_InspectionLot Lots from production receipts and their usage decisions
Costs and variances I_ProductCostEstimate, I_MfgOrderActlPlanTgtLdgrCost, I_MfgOrderEvtBsdWIPVariance Standard cost, actual, plan and target costs, event-based WIP and variances

The repository also lists released event types for production orders (created, changed, released) and for PIRs (created, changed). Check their exact names in the event catalog on the SAP Business Accelerator Hub, and see Event-driven agents with SAP events for how to react to them. The functions ConvertPlndOrder and ReleaseOrder change SAP data; use them only in a design where a person triggers the action.

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 MRP run on Monday 26 October 2026. It nets demand for two pumps and three components, creates planned orders, raises exception messages and ranks them into a planner's worklist. It checks the load on the assembly line and the test bench against their capacity. And it traces two finished production orders to settlement, explaining each variance. An optional step reads planned orders from SAP's sandbox.

flowchart LR
  P[calder_pack/<br/>products, prices] --> S[plan_to_produce.py]
  SC[Scenario in the script<br/>stock, PIRs, orders,<br/>capacity, costs] --> S
  S --> N[Netting and<br/>exception messages]
  S --> W[Ranked worklist]
  S --> C[Capacity per week]
  S --> V[Order trace and<br/>variance split]
  S --> M[calder_mrp_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 7: no account, no key and no network. The script uses only Python's built-in modules, so requirements.txt doesn't change.
  • For Step 8 (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 script checks the pumps' standard prices (2,850.00 and 4,100.00 USD) in products.json before it runs, because the cost estimates must add up to them.

Step 3: Create the script

  1. In VS Code, create a new file, paste the script below, and save it as plan_to_produce.py inside the unit1b folder.
  2. Read the blocks MATERIALS, STOCK, PIRS, SALES_ORDERS, PRODUCTION_ORDERS, PURCHASE_ORDERS, CAPACITY_PER_DAY and COST_ESTIMATE first. They are Calder's scenario written as Python data. Every value is invented. The planning data, BOMs and routings come from the master data topic, the sales orders from the order-to-cash topic and purchase order 4500000002 from the procure-to-pay topic. Lot size labels such as FX10, activity rates and the maintenance order are the course's own, not SAP values.
"""Plan to produce at Calder: net demand, read the MRP exceptions, trace one order to settlement.

Calder Pumpworks is a fictional company. The pack (unit1b/calder_pack/) and the
scenario below (stock, demand, open orders, capacity, costs) are made up for the
course. Field names follow SAP's released APIs where the script mirrors one
(A_PlannedOrder, A_ProductionOrder_2, A_PlannedIndepRqmtItem), but the planning
logic is a teaching sketch of MRP, not SAP's algorithm. Exception numbers 06, 07,
10, 15, 20 and 96 are the ones SAP documents for those situations; which ones a
system shows, and how they are grouped, is configuration.

Run it from your course folder:
  python unit1b/plan_to_produce.py                       run MRP and print the planner's worklist
  python unit1b/plan_to_produce.py --material CP-MTR15   show one material's netting, line by line
  python unit1b/plan_to_produce.py --capacity            work center load per week
  python unit1b/plan_to_produce.py --order 1100016       trace one production order to settlement
  python unit1b/plan_to_produce.py --write               write unit1b/calder_mrp_worklist.md
  python unit1b/plan_to_produce.py --sandbox             count planned orders in 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 datetime import date, timedelta
from pathlib import Path

HERE = Path(__file__).resolve().parent
PACK = HERE / "calder_pack"
AS_OF = date(2026, 10, 26)       # Monday morning: the planner reads the MRP results
HORIZON_END = date(2026, 12, 31)  # plan this far ahead
RESCHEDULE_OUT_DAYS = 10         # a receipt this many working days too early gets message 15
PLANT = "CP11"

# ------------------------------------------------- plant planning data (fictional)
# From the master data topic (A_ProductSupplyPlanning shape). Times are working days.
# LotSize is a course label: "EX" lot-for-lot, "FX10" fixed lot of 10.
MATERIALS = {
    "CP-ES200": {"Make": True, "InHouseDays": 5, "GRDays": 1, "Safety": 4, "LotSize": "FX10"},
    "CP-ES300": {"Make": True, "InHouseDays": 6, "GRDays": 1, "Safety": 2, "LotSize": "EX"},
    "CP-MTR15": {"Make": False, "DeliveryDays": 21, "GRDays": 1, "Safety": 6, "LotSize": "EX"},
    "CP-IMP200": {"Make": False, "DeliveryDays": 28, "GRDays": 2, "Safety": 8, "LotSize": "EX"},
    "CP-SK200": {"Make": False, "DeliveryDays": 10, "GRDays": 1, "Safety": 50, "LotSize": "EX"},
}
# Bills of material from the master data topic (trimmed to the pack's components).
BOM = {"CP-ES200": {"CP-IMP200": 1, "CP-MTR15": 1, "CP-SK200": 1}, "CP-ES300": {"CP-MTR15": 1}}
# Routings: hours per pump at each work center (master data topic).
ROUTING = {"CP-ES200": {"ASSY1": 1.5, "TEST1": 0.5}, "CP-ES300": {"ASSY1": 2.0, "TEST1": 0.75}}
LOW_LEVEL = ["CP-ES200", "CP-ES300", "CP-MTR15", "CP-IMP200", "CP-SK200"]  # plan top-down

# ------------------------------------------------------------ the scenario
STOCK = {"CP-ES200": 12, "CP-ES300": 3, "CP-MTR15": 5, "CP-IMP200": 30, "CP-SK200": 300}

# Planned independent requirements, after A_PlannedIndepRqmtItem: one quantity per month,
# dated on the month's first working day. Calder plans pumps with final assembly
# (strategy 40), so sales orders in a month consume that month's PIR.
PIRS = [
    {"Product": "CP-ES200", "PlndIndepRqmtPeriodStartDate": "2026-11-02", "PlannedQuantity": 20},
    {"Product": "CP-ES200", "PlndIndepRqmtPeriodStartDate": "2026-12-01", "PlannedQuantity": 20},
    {"Product": "CP-ES300", "PlndIndepRqmtPeriodStartDate": "2026-11-02", "PlannedQuantity": 6},
    {"Product": "CP-ES300", "PlndIndepRqmtPeriodStartDate": "2026-12-01", "PlannedQuantity": 6},
]
# Open sales order items from the order-to-cash topic (requested dates).
SALES_ORDERS = [
    ("1000002", "CP-ES200", 6, "2026-10-30"),
    ("1000003", "CP-ES300", 2, "2026-11-02"),
    ("1000004", "CP-ES300", 5, "2026-10-27"),
    ("1000005", "CP-SK200", 40, "2026-10-28"),
    ("1000007", "CP-ES200", 2, "2026-11-09"),
]
# Open production orders, after A_ProductionOrder_2 (dates are basic start and finish).
PRODUCTION_ORDERS = [
    {"ManufacturingOrder": "1100018", "Material": "CP-ES200", "TotalQuantity": 10,
     "MfgOrderPlannedStartDate": "2026-10-16", "MfgOrderPlannedEndDate": "2026-10-22",
     "Status": "released, assembly confirmed, test open", "ComponentsIssued": True},
    {"ManufacturingOrder": "1100019", "Material": "CP-ES200", "TotalQuantity": 20,
     "MfgOrderPlannedStartDate": "2026-11-03", "MfgOrderPlannedEndDate": "2026-11-09",
     "Status": "created", "ComponentsIssued": False},
    {"ManufacturingOrder": "1100021", "Material": "CP-ES300", "TotalQuantity": 4,
     "MfgOrderPlannedStartDate": "2026-10-28", "MfgOrderPlannedEndDate": "2026-11-05",
     "Status": "released", "ComponentsIssued": False},
]
# Open purchase orders (procure-to-pay topic, plus 4500000008 and 4500000009 for this topic).
PURCHASE_ORDERS = [
    ("4500000002", "CP-MTR15", 20, "2026-11-05", "Orrin Motor Works, not confirmed"),
    ("4500000008", "CP-IMP200", 40, "2026-11-02", "Calder GmbH (intercompany), confirmed"),
    ("4500000010", "CP-MTR15", 30, "2026-11-19", "Orrin Motor Works, confirmed"),
    ("4500000009", "CP-SK200", 100, "2026-11-12", "Ridgeline Bearing Supply, confirmed"),
]
# Work center capacity in hours per working day, and a plant maintenance order that takes
# the test bench out of service (the maintenance topic later in Unit 1B covers these).
CAPACITY_PER_DAY = {"ASSY1": 7.5, "TEST1": 4.0}
OUTAGES = [("TEST1", "2026-11-09", "2026-11-11", "maintenance order 4000123: test bench overhaul")]

# Standard cost estimate per pump, after I_ProductCostEstimate. Materials come from the
# pack's standard prices; "other BOM items" stands for the castings and parts the course
# BOM leaves out. Activity rates and overhead are course values. Totals equal the pack's
# standard prices: 2,850.00 and 4,100.00 USD.
COST_ESTIMATE = {
    "CP-ES200": [("material", "CP-IMP200", 1, 335.00), ("material", "CP-MTR15", 1, 920.00),
                 ("material", "CP-SK200", 1, 38.00), ("material", "other BOM items", 1, 1050.00),
                 ("activity", "ASSY1", 1.5, 140.00), ("activity", "TEST1", 0.5, 180.00),
                 ("overhead", "overhead", 1, 207.00)],
    "CP-ES300": [("material", "CP-MTR15", 1, 920.00), ("material", "other BOM items", 1, 2200.00),
                 ("activity", "ASSY1", 2.0, 140.00), ("activity", "TEST1", 0.75, 180.00),
                 ("overhead", "overhead", 1, 565.00)],
}
# Two finished orders from October, for the trace and the variance explanation.
DONE_ORDERS = {
    "1100015": {"Material": "CP-ES200", "TotalQuantity": 10, "Delivered": 10, "Scrap": 0,
                "Events": [("2026-09-28", "Planned order 0000050011", "MRP run", "10 x CP-ES200, start 2026-10-05"),
                           ("2026-10-02", "Production order 1100015", "Production planner", "converted from the planned order; reservations for 3 components"),
                           ("2026-10-05", "Release", "Production supervisor", "availability check OK; shop papers printed"),
                           ("2026-10-05", "Goods issue (staging)", "Warehouse", "components issued to the order"),
                           ("2026-10-08", "Confirmation 0010 ASSY1", "Operator", "yield 10, 17.5 h (plan 15.0)"),
                           ("2026-10-09", "Confirmation 0020 TEST1", "Operator", "yield 10, 5.0 h; final confirmation"),
                           ("2026-10-09", "Goods receipt", "Operator", "10 to unrestricted stock (no inspection type for ES-200)"),
                           ("2026-10-31", "Settlement", "Cost accounting", "variance to price differences")],
                "Actual": [("material", "CP-IMP200", 10, 335.00), ("material", "CP-MTR15", 10, 920.00),
                           ("material", "CP-SK200", 11, 38.00), ("material", "other BOM items", 10, 1050.00),
                           ("activity", "ASSY1", 17.5, 140.00), ("activity", "TEST1", 5.0, 180.00),
                           ("overhead", "overhead", 10, 207.00)],
                "Notes": {"CP-SK200": "one seal kit damaged at fitting", "ASSY1": "new fitter on the line"}},
    "1100016": {"Material": "CP-ES300", "TotalQuantity": 4, "Delivered": 3, "Scrap": 1,
                "Events": [("2026-10-01", "Planned order 0000050012", "MRP run", "4 x CP-ES300, start 2026-10-12"),
                           ("2026-10-08", "Production order 1100016", "Production planner", "converted; reservation for CP-MTR15"),
                           ("2026-10-12", "Release", "Production supervisor", "availability check OK"),
                           ("2026-10-12", "Goods issue (staging)", "Warehouse", "4 motors issued to the order"),
                           ("2026-10-15", "Confirmation 0010 ASSY1", "Operator", "yield 4, 8.0 h"),
                           ("2026-10-16", "Confirmation 0020 TEST1", "Operator", "yield 3, scrap 1, 4.0 h (one retest)"),
                           ("2026-10-16", "Goods receipt", "Operator", "3 to quality inspection, lot 040000000007"),
                           ("2026-10-19", "Usage decision", "Quality engineer", "accepted: 3 to unrestricted stock"),
                           ("2026-10-19", "Delivery completed", "Production planner", "no more receipts expected"),
                           ("2026-10-31", "Settlement", "Cost accounting", "variance to price differences")],
                "Actual": [("material", "CP-MTR15", 4, 920.00), ("material", "other BOM items", 4, 2200.00),
                           ("activity", "ASSY1", 8.0, 140.00), ("activity", "TEST1", 4.0, 180.00),
                           ("overhead", "overhead", 4, 565.00)],
                "Notes": {"scrap": "pump failed the pressure test", "TEST1": "one retest before the pump was scrapped"}},
}
STD_PRICE = {"CP-ES200": 2850.00, "CP-ES300": 4100.00}  # checked against products.json at start

OWNER = {
    "10": "Production planner (move the order earlier, or tell sales)",
    "15": "Buyer (ask the supplier to deliver later)",
    "20": "Buyer (cancel or reduce the order)",
    "96": "Buyer (expedite) and production planner",
    "06": "Production planner (convert now, check capacity)",
    "07": "Production supervisor (finish the order or reschedule it)",
    "10P": "Buyer (expedite the purchase order)",
    "06P": "Buyer (order now, ask for a shorter lead time)",
    "CAP": "Production supervisor, with maintenance planning",
}
TEXT = {"06": "start date in the past", "07": "finish date in the past", "10": "reschedule in",
        "15": "reschedule out", "20": "cancel process", "96": "stock below safety stock"}


# --------------------------------------------------------------- helpers
def d(text: str) -> date:
    return date.fromisoformat(text)


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


def workdays_back(day: date, n: int) -> date:
    """Go back n working days (Monday to Friday; the course ignores holidays)."""
    while n > 0:
        day -= timedelta(days=1)
        if day.weekday() < 5:
            n -= 1
    return day


def workdays_forward(day: date, n: int) -> date:
    while n > 0:
        day += timedelta(days=1)
        if day.weekday() < 5:
            n -= 1
    return day


def workdays_between(a: date, b: date) -> int:
    """Working days from a to b (negative if b is earlier)."""
    sign, lo, hi = (1, a, b) if b >= a else (-1, b, a)
    n, day = 0, lo
    while day < hi:
        day += timedelta(days=1)
        if day.weekday() < 5:
            n += 1
    return sign * n


def supplier(rec: dict) -> str:
    return f" from {rec['who']}" if rec.get("who") else ""


def check_pack() -> None:
    try:
        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).")
    for p in products["Products"]:
        if p["Product"] in STD_PRICE and p["to_Valuation"][0]["StandardPrice"] != STD_PRICE[p["Product"]]:
            sys.exit(f"The standard price of {p['Product']} in the pack has changed. "
                     "Run python unit1b/make_calder_pack.py again.")
    for material, lines in COST_ESTIMATE.items():
        total = round(sum(q * r for _, _, q, r in lines), 2)
        if total != STD_PRICE[material]:
            sys.exit(f"Cost estimate for {material} adds up to {money(total)}, not {money(STD_PRICE[material])}.")


# --------------------------------------------------------------- demand
def independent_demand(material: str) -> list:
    """Sales orders plus the part of each month's PIR that sales orders didn't consume."""
    rows = []
    for so, mat, qty, day in SALES_ORDERS:
        if mat == material:
            rows.append({"date": d(day), "qty": qty, "what": f"sales order {so}", "customer": True})
    for pir in PIRS:
        if pir["Product"] != material:
            continue
        start = d(pir["PlndIndepRqmtPeriodStartDate"])
        in_month = sum(q for _, m, q, day in SALES_ORDERS
                       if m == material and d(day).year == start.year and d(day).month == start.month)
        rest = max(pir["PlannedQuantity"] - in_month, 0)
        note = f"PIR {start:%b} (rest of {pir['PlannedQuantity']} after {in_month} on orders)"
        if rest:
            rows.append({"date": start, "qty": rest, "what": note, "customer": False})
    return rows


# --------------------------------------------------------------- MRP (teaching sketch)
def plan_material(material: str, demand: list, receipts: list) -> dict:
    """Net one material: check existing receipts, then create proposals for what is still short.

    demand:   [{"date", "qty", "what", "customer"}]
    receipts: [{"date", "qty", "what", "kind"}]  kind: production order / purchase order
    """
    m = MATERIALS[material]
    demand = sorted(demand, key=lambda r: (r["date"], r["what"]))
    receipts = sorted(receipts, key=lambda r: (r["date"], r["what"]))
    available = STOCK[material] - m["Safety"]
    messages = []
    if STOCK[material] < m["Safety"]:
        messages.append({"code": "96", "material": material, "element": "stock",
                         "detail": f"stock {STOCK[material]} is below safety stock {m['Safety']}", "value": 0})

    def need_date(covered: float):
        """First demand date on which cumulative demand exceeds what is already covered."""
        cum = 0
        for row in demand:
            cum += row["qty"]
            if cum > covered:
                return row["date"], row
        return None, None

    covered = available
    for rec in receipts:
        when, row = need_date(covered)
        rec["needed"] = when
        if when is None:
            messages.append({"code": "20", "material": material, "element": rec["what"],
                             "detail": f"{rec['qty']} due {rec['date']}{supplier(rec)}, not needed before {HORIZON_END}",
                             "value": rec["qty"], "receipt": rec})
        elif when < rec["date"]:
            messages.append({"code": "10" if rec["kind"] == "production order" else "10P",
                             "material": material, "element": rec["what"],
                             "detail": f"{rec['qty']} due {rec['date']}{supplier(rec)}, needed {when} for {row['what']}",
                             "value": rec["qty"], "receipt": rec, "customer": row["customer"],
                             "late_days": workdays_between(when, rec["date"])})
        elif workdays_between(rec["date"], when) > RESCHEDULE_OUT_DAYS:
            messages.append({"code": "15", "material": material, "element": rec["what"],
                             "detail": f"{rec['qty']} due {rec['date']}{supplier(rec)}, not needed until {when}",
                             "value": rec["qty"], "receipt": rec})
        covered += rec["qty"]

    proposals = []
    cum = 0
    by_date = {}
    for row in demand:  # one proposal per day at most: merge the day's demand
        day = by_date.setdefault(row["date"], {"date": row["date"], "qty": 0, "what": [], "customer": False})
        day["qty"] += row["qty"]
        day["what"].append(row["what"])
        day["customer"] = day["customer"] or row["customer"]
    for row in by_date.values():
        row["what"] = " + ".join(row["what"])
        cum += row["qty"]
        short = cum - covered
        if short <= 0:
            continue
        qty = short
        if m["LotSize"].startswith("FX"):
            lot = int(m["LotSize"][2:])
            qty = -(-short // lot) * lot  # round up to whole lots
        finish = workdays_back(row["date"], m["GRDays"])
        start = workdays_back(finish, m["InHouseDays"] if m["Make"] else m["DeliveryDays"])
        prop = {"material": material, "qty": qty, "needed": row["date"], "start": start, "finish": finish,
                "kind": "planned order" if m["Make"] else "purchase requisition", "for": row["what"]}
        if start < AS_OF:  # SAP switches to forward scheduling when the start date is in the past
            prop["start"] = AS_OF
            prop["finish"] = workdays_forward(AS_OF, m["InHouseDays"] if m["Make"] else m["DeliveryDays"])
            prop["available"] = workdays_forward(prop["finish"], m["GRDays"])
            messages.append({"code": "06" if m["Make"] else "06P", "material": material, "element": f"new {prop['kind']}",
                             "detail": f"{qty} needed {row['date']} for {row['what']}; "
                                       f"forward-scheduled, available {prop['available']}",
                             "value": qty, "customer": row["customer"],
                             "late_days": workdays_between(row["date"], prop["available"])})
        else:
            prop["available"] = row["date"]
        proposals.append(prop)
        covered += qty
    return {"material": material, "demand": demand, "receipts": receipts,
            "proposals": proposals, "messages": messages}


def run_mrp() -> dict:
    """Plan materials by low-level code: finished pumps first, then their components."""
    results, dependent = {}, {mat: [] for mat in MATERIALS}
    for material in LOW_LEVEL:
        m = MATERIALS[material]
        receipts = []
        for po in PRODUCTION_ORDERS:
            if po["Material"] == material:
                end = d(po["MfgOrderPlannedEndDate"])
                avail = workdays_forward(end, m["GRDays"])
                receipts.append({"date": max(avail, AS_OF), "qty": po["TotalQuantity"], "kind": "production order",
                                 "what": f"production order {po['ManufacturingOrder']}", "order": po})
        for number, mat, qty, day, who in PURCHASE_ORDERS:
            if mat == material:
                receipts.append({"date": d(day), "qty": qty, "kind": "purchase order",
                                 "what": f"purchase order {number}", "who": who})
        demand = independent_demand(material) + dependent[material]
        res = plan_material(material, demand, receipts)
        # finish date in the past for production orders still open
        for po in PRODUCTION_ORDERS:
            if po["Material"] == material and d(po["MfgOrderPlannedEndDate"]) < AS_OF:
                res["messages"].append({"code": "07", "material": material,
                                        "element": f"production order {po['ManufacturingOrder']}",
                                        "detail": f"finish {po['MfgOrderPlannedEndDate']}, status {po['Status']}",
                                        "value": po["TotalQuantity"], "customer": False})
        results[material] = res
        # explode BOMs: open production orders (not yet issued) and new planned orders
        for comp, per in BOM.get(material, {}).items():
            for po in PRODUCTION_ORDERS:
                if po["Material"] == material and not po["ComponentsIssued"]:
                    dependent[comp].append({"date": max(d(po["MfgOrderPlannedStartDate"]), AS_OF),
                                            "qty": per * po["TotalQuantity"], "customer": False,
                                            "what": f"reservation for order {po['ManufacturingOrder']}"})
            for i, prop in enumerate(res["proposals"], 1):
                dependent[comp].append({"date": prop["start"], "qty": per * prop["qty"], "customer": False,
                                        "what": f"planned order {material}/{i}"})
    return results


# --------------------------------------------------------------- worklist
def worklist(results: dict) -> list:
    """One line per message, most urgent first: customer impact, then lateness, then date."""
    rows = []
    for res in results.values():
        for msg in res["messages"]:
            late = msg.get("late_days", 0)
            score = (5 if msg.get("customer") else 0) + min(late, 10) / 2
            if msg["code"] in ("96", "07"):
                score += 2
            if msg["code"] in ("15", "20"):
                score += 0.5
            rows.append({**msg, "score": score, "owner": OWNER[msg["code"]]})
    return sorted(rows, key=lambda r: (-r["score"], r["material"], r["element"]))


def capacity(results: dict) -> dict:
    """Hours per work center and week (Monday). Course rule: each operation's load falls in
    the week of the order's finish date; SAP schedules each operation on its own dates."""
    load = {}
    orders = [(po["Material"], po["TotalQuantity"], d(po["MfgOrderPlannedEndDate"]), po["ManufacturingOrder"])
              for po in PRODUCTION_ORDERS if d(po["MfgOrderPlannedEndDate"]) >= AS_OF]
    for res in results.values():
        for i, prop in enumerate(res["proposals"], 1):
            if prop["kind"] == "planned order":
                orders.append((prop["material"], prop["qty"], prop["finish"], f"planned {prop['material']}/{i}"))
    for material, qty, finish, label in orders:
        week = finish - timedelta(days=finish.weekday())
        for wc, hours in ROUTING[material].items():
            entry = load.setdefault((wc, week), {"hours": 0.0, "orders": []})
            entry["hours"] += hours * qty
            entry["orders"].append(label)
    table = {}
    for (wc, week), entry in sorted(load.items()):
        days = [week + timedelta(days=i) for i in range(5)]
        down = sum(1 for day in days for w, a, b, _ in OUTAGES if w == wc and d(a) <= day <= d(b))
        avail = CAPACITY_PER_DAY[wc] * (5 - down)
        table[(wc, week)] = {"load": entry["hours"], "available": avail, "orders": entry["orders"], "down": down}
    return table


def print_worklist(results: dict) -> list:
    rows = worklist(results)
    print(f"MRP results for plant {PLANT} as of {AS_OF} (fictional Calder data)\n")
    print("New proposals:")
    for res in results.values():
        for p in res["proposals"]:
            late = "" if p["available"] == p["needed"] else f"  (needed {p['needed']})"
            print(f"  {p['kind']:<21} {p['material']:<10} {p['qty']:>4}  start {p['start']}  "
                  f"available {p['available']}{late}")
    print("\nException messages, most urgent first:\n")
    print(f"  #  {'Msg':<4}{'Material':<11}{'Element':<27}{'Owner'}")
    for i, r in enumerate(rows, 1):
        code = r["code"].rstrip("P")
        print(f"  {i:<2} {code:<4}{r['material']:<11}{r['element']:<27}{r['owner']}")
        print(f"  {'':<2} {'':<4}{'':<11}{TEXT[code]}: {r['detail']}")
    cap = capacity(results)
    over = [(k, v) for k, v in cap.items() if v["load"] > v["available"]]
    print("\nCapacity overloads (load above available hours):")
    for (wc, week), v in over:
        pct = 100 * v["load"] / v["available"] if v["available"] else float("inf")
        print(f"  {wc} week of {week}: {v['load']:.1f} h load, {v['available']:.1f} h available ({pct:.0f}%)"
              f"{'; ' + str(v['down']) + ' days down for maintenance' if v['down'] else ''}")
    if not over:
        print("  none")
    customer = sum(1 for r in rows if r.get("customer"))
    print(f"\n  {len(rows)} messages, {customer} of them touch a customer order; {len(over)} capacity overload(s).")
    print("  Synthetic data: the list shows the method, not results from an SAP system.")
    return rows


def show_material(results: dict, material: str) -> None:
    if material not in results:
        sys.exit(f"Material {material} is not in the scenario. Use one of: {', '.join(results)}")
    res, m = results[material], MATERIALS[material]
    print(f"{material} at {PLANT}: stock {STOCK[material]}, safety stock {m['Safety']}, "
          f"lot size {m['LotSize']} (fictional)\n")
    events = [(r["date"], -r["qty"], r["what"]) for r in res["demand"]]
    events += [(r["date"], r["qty"], r["what"]) for r in res["receipts"]]
    events += [(p["available"], p["qty"], f"NEW {p['kind']} (start {p['start']})") for p in res["proposals"]]
    level = STOCK[material]
    print(f"  {'Date':<11}{'Element':<58}{'Qty':>6}{'Projected':>11}")
    print(f"  {AS_OF!s:<11}{'stock':<58}{'':>6}{level:>11}")
    for day, qty, what in sorted(events, key=lambda e: (e[0], e[1] < 0, e[2])):
        level += qty
        flag = "  < safety" if level < m["Safety"] else ""
        print(f"  {day!s:<11}{what[:56]:<58}{qty:>+6}{level:>11}{flag}")
    print("\n  Messages:")
    for msg in res["messages"] or [{"code": "--", "detail": "none"}]:
        code = msg["code"].rstrip("P")
        print(f"    {code}  {TEXT.get(code, '')}{': ' if code in TEXT else ''}{msg['detail']}")


def print_capacity(results: dict) -> None:
    print(f"Work center load per week at {PLANT} (fictional; load counted in the week each order finishes)\n")
    print(f"  {'Work center':<12}{'Week of':<12}{'Load h':>8}{'Avail h':>9}{'Use':>7}  Orders")
    for (wc, week), v in capacity(results).items():
        pct = f"{100 * v['load'] / v['available']:.0f}%" if v["available"] else "n/a"
        mark = "  OVER" if v["load"] > v["available"] else ""
        print(f"  {wc:<12}{week!s:<12}{v['load']:>8.1f}{v['available']:>9.1f}{pct:>7}  "
              f"{', '.join(v['orders'])}{mark}")
    for wc, a, b, why in OUTAGES:
        print(f"\n  {wc} is down {a} to {b}: {why}.")
    print("  MRP plans with infinite capacity; this check is where the overload shows.")


# --------------------------------------------------------------- one order, cost and variance
def explain_order(number: str) -> None:
    if number not in DONE_ORDERS:
        sys.exit(f"Order {number} is not in the scenario. Use one of: {', '.join(DONE_ORDERS)}")
    o = DONE_ORDERS[number]
    material, qty, good, scrap = o["Material"], o["TotalQuantity"], o["Delivered"], o["Scrap"]
    print(f"Production order {number}: {qty} x {material}, delivered {good}, scrap {scrap} (fictional)\n")
    for day, what, who, note in o["Events"]:
        print(f"  {day}  {what:<26}{who:<22}{note}")
    estimate = {name: (q, r) for _, name, q, r in COST_ESTIMATE[material]}
    actual = {name: (q, r) for _, name, q, r in o["Actual"]}
    total_actual = round(sum(q * r for q, r in actual.values()), 2)
    credit = round(good * STD_PRICE[material], 2)
    variance = round(total_actual - credit, 2)
    print(f"\nCosts on the order (USD):")
    print(f"  actual debits      {money(total_actual):>12}  goods issues, confirmations, overhead")
    print(f"  credit from GR     {money(-credit):>12}  {good} x {money(STD_PRICE[material])} standard price")
    print(f"  balance (variance) {money(variance):>12}  settled to price differences at period end")
    # Variance split: target = cost estimate x quantity that went through the order.
    print("\nWhy (variance split, course version of SAP's categories):")
    rows = []
    if scrap:
        scrap_cost = round(scrap * STD_PRICE[material], 2)
        rows.append(("scrap", "all", scrap_cost, f"{scrap} pump scrapped at the last operation, valued at target"))
    for name, (q_est, rate) in estimate.items():
        q_act, rate_act = actual[name]
        target_qty = q_est * qty  # target for everything that went through the order, scrap included
        qvar = round((q_act - target_qty) * rate, 2)
        pvar = round((rate_act - rate) * q_act, 2)
        if qvar:
            rows.append(("input quantity", name, qvar, f"{q_act:g} used vs {target_qty:g} planned"
                         + (f"; {o['Notes'][name]}" if name in o["Notes"] else "")))
        if pvar:
            rows.append(("input price", name, pvar, f"rate {money(rate_act)} vs {money(rate)}"))
    explained = round(sum(v for _, _, v, _ in rows), 2)
    if round(variance - explained, 2):
        rows.append(("remaining", "", round(variance - explained, 2), "not explained by the rules above"))
    for cat, name, value, why in rows:
        print(f"  {cat:<15}{name:<17}{money(value):>10}  {why}")
    if scrap:
        print(f"  (scrap reason: {o['Notes']['scrap']})")
    print(f"  {'total':<32}{money(variance):>10}")
    print("\n  Synthetic numbers for a fictional order, not from an SAP system.")


# --------------------------------------------------------------- markdown
FENCE = "`" * 3  # a Markdown code fence, built so this script can sit inside one


def write_markdown(results: dict, rows: list) -> Path:
    out = HERE / "calder_mrp_worklist.md"
    lines = [f"# Calder MRP worklist, plant {PLANT}, {AS_OF}", "",
             "Fictional data from the plan-to-produce topic. Shows the method, not results from SAP.", "",
             FENCE + "mermaid", "flowchart LR",
             "  D[Demand<br/>PIRs, sales orders] --> M[MRP run]", "  M --> PL[Planned orders<br/>requisitions]",
             "  M --> EX[Exception messages]", "  PL --> PO[Production orders]",
             "  PO --> GI[Components issued] --> CF[Confirmations] --> GR[Goods receipt]",
             "  GR --> ST[Settlement<br/>variances]", FENCE, "",
             "| # | Msg | Material | Element | What | Owner |", "| --- | --- | --- | --- | --- | --- |"]
    for i, r in enumerate(rows, 1):
        code = r["code"].rstrip("P")
        lines.append(f"| {i} | {code} | {r['material']} | {r['element']} | {TEXT[code]}: {r['detail']} | {r['owner']} |")
    lines += ["", "## Capacity", "", "| Work center | Week of | Load h | Available h |", "| --- | --- | --- | --- |"]
    for (wc, week), v in capacity(results).items():
        lines.append(f"| {wc} | {week} | {v['load']:.1f} | {v['available']:.1f} |")
    out.write_text("\n".join(lines) + "\n", encoding="utf-8")
    return out


# --------------------------------------------------------------- SAP 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_PLANNED_ORDERS/A_PlannedOrder"
    query = urllib.parse.urlencode({"$top": "50", "$format": "json",
                                    "$select": "PlannedOrder,Material,MRPPlant,TotalQuantity,"
                                               "PlndOrderPlannedStartDate,PlannedOrderIsFirm,PlannedOrderIsConvertible"})
    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", [])
    plants, firm = {}, 0
    for it in items:
        plants[it.get("MRPPlant", "")] = plants.get(it.get("MRPPlant", ""), 0) + 1
        firm += 1 if it.get("PlannedOrderIsFirm") else 0
    print(f"SAP sandbox: {len(items)} planned orders read (SAP demo data, not Calder's)\n")
    print("  MRPPlant: " + ", ".join(f"'{p or '(empty)'}' x{n}" for p, n in sorted(plants.items())))
    print(f"  firmed: {firm} of {len(items)}")
    for it in items[:3]:
        print(f"  e.g. {it.get('PlannedOrder')}  {it.get('Material')}  qty {it.get('TotalQuantity')}")


# --------------------------------------------------------------- main
def main() -> None:
    parser = argparse.ArgumentParser(description="Plan to produce at Calder (fictional data).")
    parser.add_argument("--material", help="show one material's netting, e.g. CP-MTR15")
    parser.add_argument("--capacity", action="store_true", help="work center load per week")
    parser.add_argument("--order", help="trace one finished production order, e.g. 1100016")
    parser.add_argument("--write", action="store_true", help="write unit1b/calder_mrp_worklist.md")
    parser.add_argument("--sandbox", action="store_true", help="count planned orders in SAP's sandbox")
    args = parser.parse_args()
    if args.sandbox:
        sandbox()
        return
    check_pack()
    if args.order:
        explain_order(args.order)
        return
    results = run_mrp()
    if args.material:
        show_material(results, args.material)
    elif args.capacity:
        print_capacity(results)
    else:
        rows = print_worklist(results)
        if args.write:
            print(f"\nwrote unit1b/{write_markdown(results, rows).name}")


if __name__ == "__main__":
    main()

Step 4: Run MRP and read the worklist

  1. Run the script:

    • Windows (PowerShell):

      python unit1b\plan_to_produce.py
    • macOS / Linux:

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

MRP results for plant CP11 as of 2026-10-26 (fictional Calder data)

New proposals:
  planned order         CP-ES200     10  start 2026-11-23  available 2026-12-01
  planned order         CP-ES300      6  start 2026-10-26  available 2026-11-04  (needed 2026-11-02)
  planned order         CP-ES300      6  start 2026-11-20  available 2026-12-01

Exception messages, most urgent first:

  #  Msg Material   Element                    Owner
  1  10  CP-ES300   production order 1100021   Production planner (move the order earlier, or tell sales)
                    reschedule in: 4 due 2026-11-06, needed 2026-10-27 for sales order 1000004
  2  06  CP-ES300   new planned order          Production planner (convert now, check capacity)
                    start date in the past: 6 needed 2026-11-02 for PIR Nov (rest of 6 after 2 on orders) + sales order 1000003; forward-scheduled, available 2026-11-04
  3  10  CP-MTR15   purchase order 4500000010  Buyer (expedite the purchase order)
                    reschedule in: 30 due 2026-11-19 from Orrin Motor Works, confirmed, needed 2026-11-03 for reservation for order 1100019
  4  10  CP-MTR15   purchase order 4500000002  Buyer (expedite the purchase order)
                    reschedule in: 20 due 2026-11-05 from Orrin Motor Works, not confirmed, needed 2026-10-26 for planned order CP-ES300/1
  5  10  CP-ES200   production order 1100019   Production planner (move the order earlier, or tell sales)
                    reschedule in: 20 due 2026-11-10, needed 2026-11-02 for PIR Nov (rest of 20 after 2 on orders)
  6  07  CP-ES200   production order 1100018   Production supervisor (finish the order or reschedule it)
                    finish date in the past: finish 2026-10-22, status released, assembly confirmed, test open
  7  96  CP-MTR15   stock                      Buyer (expedite) and production planner
                    stock below safety stock: stock 5 is below safety stock 6
  8  15  CP-IMP200  purchase order 4500000008  Buyer (ask the supplier to deliver later)
                    reschedule out: 40 due 2026-11-02 from Calder GmbH (intercompany), confirmed, not needed until 2026-11-23
  9  20  CP-SK200   purchase order 4500000009  Buyer (cancel or reduce the order)
                    cancel process: 100 due 2026-11-12 from Ridgeline Bearing Supply, confirmed, not needed before 2026-12-31

Capacity overloads (load above available hours):
  TEST1 week of 2026-11-09: 10.0 h load, 8.0 h available (125%); 3 days down for maintenance

  9 messages, 2 of them touch a customer order; 1 capacity overload(s).
  Synthetic data: the list shows the method, not results from an SAP system.

How to read it:

  • New proposals are what MRP adds. The first ES-300 planned order should have started on 22 October to be ready on 2 November. That date is past, so the script schedules it forward from today, as SAP does, and it arrives on 4 November with message 06.
  • Message 10 on order 1100021 is order 1000004 from the order-to-cash topic seen from the plant: Brennock wants five ES-300 on 27 October, three are in stock with two of them held as safety stock, and the order that covers the rest finishes on 6 November. MRP can't pull a released order forward by itself. The planner has to decide whether the plant can, and sales has to tell the customer if it can't.
  • Messages 3, 4 and 7 are one problem: motors. Five are in stock against a safety stock of six, and the two purchase orders arrive after the line needs them. Purchase order 4500000002 is the unconfirmed one from the procure-to-pay worklist. Here it shows why a missing confirmation matters to the plant: MRP is planning on a date nobody promised.
  • Messages 8 and 9 are the quiet ones: impellers that come three weeks early and seal kits that aren't needed at all. They cost cash, not customers, so they rank last.
  • The ranking is the script's own: a customer order at stake first, then how late, then stock and past-date problems, then early or unneeded receipts. SAP lets you group and prioritize messages in configuration; the order a plant chooses is a business decision.

Step 5: Look at one material, line by line

  1. Show the netting for the motor:

    • Windows (PowerShell):

      python unit1b\plan_to_produce.py --material CP-MTR15
    • macOS / Linux:

      python unit1b/plan_to_produce.py --material CP-MTR15
  2. You should see:

CP-MTR15 at CP11: stock 5, safety stock 6, lot size EX (fictional)

  Date       Element                                                      Qty  Projected
  2026-10-26 stock                                                                     5
  2026-10-26 planned order CP-ES300/1                                      -6         -1  < safety
  2026-10-28 reservation for order 1100021                                 -4         -5  < safety
  2026-11-03 reservation for order 1100019                                -20        -25  < safety
  2026-11-05 purchase order 4500000002                                    +20         -5  < safety
  2026-11-19 purchase order 4500000010                                    +30         25
  2026-11-20 planned order CP-ES300/2                                      -6         19
  2026-11-23 planned order CP-ES200/1                                     -10          9

  Messages:
    96  stock below safety stock: stock 5 is below safety stock 6
    10  reschedule in: 20 due 2026-11-05 from Orrin Motor Works, not confirmed, needed 2026-10-26 for planned order CP-ES300/1
    10  reschedule in: 30 due 2026-11-19 from Orrin Motor Works, confirmed, needed 2026-11-03 for reservation for order 1100019

This is the shape of MD04: stock, then each requirement and receipt in date order, with the projected stock after each. The motor's requirements are dependent: they come from the pumps' planned orders and from the reservations of production orders whose components aren't issued yet. Projected stock is negative from 26 October to 19 November. Order 1100019 needs 20 motors on 3 November, and only the 30 due on 19 November cover it. That is the shortage the availability check will report when someone tries to release the order. Try --material CP-ES300 and --material CP-IMP200 too.

Step 6: Check capacity

  1. Run:

    • Windows (PowerShell):

      python unit1b\plan_to_produce.py --capacity
    • macOS / Linux:

      python unit1b/plan_to_produce.py --capacity
  2. You should see:

Work center load per week at CP11 (fictional; load counted in the week each order finishes)

  Work center Week of       Load h  Avail h    Use  Orders
  ASSY1       2026-11-02      20.0     37.5    53%  1100021, planned CP-ES300/1
  ASSY1       2026-11-09      30.0     37.5    80%  1100019
  ASSY1       2026-11-30      27.0     37.5    72%  planned CP-ES200/1, planned CP-ES300/2
  TEST1       2026-11-02       7.5     20.0    38%  1100021, planned CP-ES300/1
  TEST1       2026-11-09      10.0      8.0   125%  1100019  OVER
  TEST1       2026-11-30       9.5     20.0    48%  planned CP-ES200/1, planned CP-ES300/2

  TEST1 is down 2026-11-09 to 2026-11-11: maintenance order 4000123: test bench overhaul.
  MRP plans with infinite capacity; this check is where the overload shows.

Nothing in MRP's output warned about the test bench. MRP planned order 1100019 into the week of 9 November as if the bench were free, and a maintenance order takes it out for three of those five days. Ten hours of testing meet eight hours of bench. The fix is a decision between three people: move the order earlier (which the motor shortage in Step 5 doesn't allow), move the maintenance, or test on overtime. The script counts each order's hours in the week it finishes, which is cruder than SAP's per-operation scheduling but enough to see the overload.

Step 7: Trace an order to settlement and explain its variance

  1. Trace the ES-200 order from October:

    • Windows (PowerShell):

      python unit1b\plan_to_produce.py --order 1100015
    • macOS / Linux:

      python unit1b/plan_to_produce.py --order 1100015
  2. You should see:

Production order 1100015: 10 x CP-ES200, delivered 10, scrap 0 (fictional)

  2026-09-28  Planned order 0000050011  MRP run               10 x CP-ES200, start 2026-10-05
  2026-10-02  Production order 1100015  Production planner    converted from the planned order; reservations for 3 components
  2026-10-05  Release                   Production supervisor availability check OK; shop papers printed
  2026-10-05  Goods issue (staging)     Warehouse             components issued to the order
  2026-10-08  Confirmation 0010 ASSY1   Operator              yield 10, 17.5 h (plan 15.0)
  2026-10-09  Confirmation 0020 TEST1   Operator              yield 10, 5.0 h; final confirmation
  2026-10-09  Goods receipt             Operator              10 to unrestricted stock (no inspection type for ES-200)
  2026-10-31  Settlement                Cost accounting       variance to price differences

Costs on the order (USD):
  actual debits         28,888.00  goods issues, confirmations, overhead
  credit from GR       -28,500.00  10 x 2,850.00 standard price
  balance (variance)       388.00  settled to price differences at period end

Why (variance split, course version of SAP's categories):
  input quantity CP-SK200              38.00  11 used vs 10 planned; one seal kit damaged at fitting
  input quantity ASSY1                350.00  17.5 used vs 15 planned; new fitter on the line
  total                               388.00

  Synthetic numbers for a fictional order, not from an SAP system.
  1. Now the ES-300 order with a scrapped pump, --order 1100016:
Production order 1100016: 4 x CP-ES300, delivered 3, scrap 1 (fictional)

  2026-10-01  Planned order 0000050012  MRP run               4 x CP-ES300, start 2026-10-12
  2026-10-08  Production order 1100016  Production planner    converted; reservation for CP-MTR15
  2026-10-12  Release                   Production supervisor availability check OK
  2026-10-12  Goods issue (staging)     Warehouse             4 motors issued to the order
  2026-10-15  Confirmation 0010 ASSY1   Operator              yield 4, 8.0 h
  2026-10-16  Confirmation 0020 TEST1   Operator              yield 3, scrap 1, 4.0 h (one retest)
  2026-10-16  Goods receipt             Operator              3 to quality inspection, lot 040000000007
  2026-10-19  Usage decision            Quality engineer      accepted: 3 to unrestricted stock
  2026-10-19  Delivery completed        Production planner    no more receipts expected
  2026-10-31  Settlement                Cost accounting       variance to price differences

Costs on the order (USD):
  actual debits         16,580.00  goods issues, confirmations, overhead
  credit from GR       -12,300.00  3 x 4,100.00 standard price
  balance (variance)     4,280.00  settled to price differences at period end

Why (variance split, course version of SAP's categories):
  scrap          all                4,100.00  1 pump scrapped at the last operation, valued at target
  input quantity TEST1                180.00  4 used vs 3 planned; one retest before the pump was scrapped
  (scrap reason: pump failed the pressure test)
  total                             4,280.00

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

Order 1100015 is the clean case: the variance is small and every dollar has a cause someone wrote down at the time. Order 1100016 is the expensive one: one pump failed the pressure test after all the work was done, so a whole pump's target cost shows as scrap. The script values scrap at the full target cost because it happened at the last operation. Without the operator's scrap quantity and reason in the confirmation, the 4,280.00 USD would appear in finance as one unexplained number. That is why confirmations matter to controlling as much as to the shop floor.

  1. Write the worklist file:

    • Windows (PowerShell):

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

      python unit1b/plan_to_produce.py --write
  2. The last line should read wrote unit1b/calder_mrp_worklist.md. Open the file in VS Code and press Ctrl+Shift+V (Windows, Linux) or Cmd+Shift+V (macOS) to preview it: a process diagram, the nine messages with owners and the weekly load table.

Step 8 (optional): Read planned orders 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\plan_to_produce.py --sandbox
    • macOS / Linux:

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

  MRPPlant: '<plant>' x<count>
  firmed: <count> of <n>
  e.g. <planned order>  <material>  qty <quantity>

The script reads up to 50 planned orders from API_PLANNED_ORDERS with $select. A valid "empty" result is 0 planned orders read: the sandbox simply has none at that moment. The sandbox is read-only, so you can't convert anything there.

Step 9: Save your work in Git

git add unit1b/plan_to_produce.py unit1b/calder_mrp_worklist.md
git commit -m "Unit 1B: plan-to-produce MRP worklist and variance trace for Calder"

What each part of the code does

Part What it does
MATERIALS, BOM, ROUTING Planning data, BOMs and routings from the master data topic: lead times, safety stock, lot size, hours per pump
STOCK, PIRS, SALES_ORDERS Stock at CP11, monthly PIRs, and the open sales orders from the order-to-cash topic
PRODUCTION_ORDERS, PURCHASE_ORDERS Open receipts MRP must check: three production orders and four purchase orders
independent_demand() Lets the month's sales orders consume the month's PIR, so demand isn't counted twice
plan_material() Netting: finds the date each receipt is needed, raises 10, 15 or 20, then creates proposals for what is still short, scheduled backward, or forward with 06 when the start is past
run_mrp() Plans by low-level code and explodes BOMs: planned orders and unissued reservations become the components' demand; adds 07 for open orders past their finish date
worklist() Ranks messages: customer orders first, then lateness, then stock and past dates, then early or unneeded receipts; names one owner
capacity(), --capacity Hours per work center and week against available hours, minus the maintenance outage
explain_order(), --order The order's events, its debits and credit, and the variance split into scrap, input quantity, input price and remaining
--material, --write One material's netting as an MD04-like list; the worklist as a Markdown file
sandbox() Reads up to 50 sandbox planned orders with $select and counts plants and firmed orders

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
The standard price of ... has changed The pack was edited Run Step 2 again
Cost estimate for ... adds up to ... A line in COST_ESTIMATE was changed Copy the script again, or make the lines add up to the standard price
Your output differs from the one shown Stock, dates or quantities in the script were changed Copy the script again, or compare your edits
Material ... is not in the scenario / Order ... is not in the scenario You asked for something the script doesn't have Use one of the values 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 8
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 7
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), plan to produce for CP11 runs inside S/4HANA: demand, MRP Live, production orders and costing. The shop floor reports through a manufacturing execution system (MES), and the Rockford distribution center stages components through EWM. Plant maintenance plans test bench outages in the same S/4HANA system. CP20 in Augsburg plans and makes impellers on ECC. Anything that ranks or explains reads released interfaces and writes only to a worklist; orders are changed in SAP's own apps.

flowchart LR
  IBP[Demand planning<br/>forecast] -->|PIRs| S4[S/4HANA CP11<br/>MRP Live, orders,<br/>costing]
  S4 -->|released orders| MES[MES<br/>shop floor]
  MES -->|confirmations,<br/>goods movements| S4
  S4 <-->|staging| EWM[EWM<br/>Rockford DC]
  PM[Plant maintenance<br/>outages] -.->|capacity| S4
  ECC[ECC CP20<br/>impellers] -.->|intercompany| S4
  S4 -->|released APIs, CDS| TRI[Exception triage<br/>read-only]
  TRI --> WL[Worklist per planner]
  WL -->|planner acts in SAP app| S4

Non-functional requirements that decide the design

Requirement Typical target If missed
MRP run time The nightly run finishes before the morning shift starts planning Planners work on yesterday's plan
Exception volume Each planner's daily list is short enough to finish, ranked by impact The easy messages get fixed, not the important ones
Confirmation latency Shop floor confirmations reach S/4HANA within minutes to hours Stock and order status are wrong; MRP plans on stale data
Capacity accuracy Work center capacity includes shifts and planned maintenance Overloads appear on the day, not the week before
Period-end close WIP and variances run and settle within the close calendar Inventory and margin reports are late or wrong
Security Planners change only their MRP controllers' materials; cost data is restricted Unauthorized order changes; exposed product costs

Integration and identity. An MES such as SAP Digital Manufacturing receives materials, work centers, BOMs, routings and production orders from S/4HANA, and sends back confirmations with yield, scrap and time, goods issues and receipts, inspection results and status updates (SAP Learning). When planning moves to SAP IBP and PP/DS, IBP builds the network supply plan and PP/DS schedules planned and production orders finitely in the plant, sending changed dates back to IBP (SAP Learning). A triage service reads with the planner's identity where possible, so authorizations on plant and MRP controller apply (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 Rockford plant has one bottleneck, the test bench, and many standard components. Planners see dozens of MRP messages a day and catch capacity problems late. Augsburg is still on ECC.
  • Options: (a) MRP Live only, with capacity checked by hand in Manage Work Center Capacity; (b) MRP Live for components and PP/DS for the pumps that load the test bench, with finite scheduling; (c) move supply planning to SAP IBP and schedule in PP/DS.
  • Decision: (b). PP/DS is aimed at critical products on bottleneck resources, and SAP's training says to plan all products that share those resources together. Components stay in MRP Live. IBP waits until Augsburg is on S/4HANA and Calder needs network-wide planning.
  • Consequences: Pump materials need PP/DS settings and the test bench a finite resource. Planners learn a second tool. Maintenance outages must reach the bench's capacity. Licensing for PP/DS must be checked with SAP for Calder's edition before the design is final.

What a design review will ask

  1. Which materials does MRP Live plan, which does PP/DS or IBP plan, and who owns the boundary?
  2. How do maintenance orders reduce work center capacity, and how far ahead?
  3. How quickly do shop floor confirmations and goods movements reach S/4HANA, and what happens when the MES is down?
  4. Can any component reschedule, convert, release or cancel an order without a planner's decision?
  5. How will you measure success: on-time order completion, exceptions closed per day, variance explained within the close?

Where AI fits, and where it doesn't

Step Pattern Fit Note
Forecast to PIRs Predict Strong Forecasting is the classic ML job here; see time-series forecasting
MRP netting and scheduling Plain automation is enough Weak for AI Deterministic rules on master data; AI shouldn't replace them
Exception triage Rank, retrieve and explain Strong Rank by customer and cost impact, gather the order, component and capacity facts, propose the action and the owner. SAP lists Planning Assistant agents for exception management as planned for Q4 2026 (SAP)
Late production orders Predict Strong Predict which orders will finish late from history, as in predicting late deliveries
Capacity what-if Plain automation, then explain Moderate Simulation belongs in pMRP or PP/DS; AI can summarize the options
Shortage at release Retrieve and explain Strong Link the short component to its purchase order, confirmation and supplier history
Confirmations Extract Moderate Turn shift notes and scrap reasons into structured confirmation data for a person to check
Variance explanation Retrieve and explain Strong Join the variance categories with confirmations, scrap reasons and goods issues; explaining model predictions covers how to show the reasons
Unusual variances Detect anomalies Moderate Catch orders whose variance doesn't fit their history; see anomaly detection
Production master data Retrieve and suggest Moderate SAP's Sapphire 2026 guide lists production master data AI capabilities with the Manufacturing Assistant and a smart recommender for manufacturing BOMs (SAP)

The line is the same at every step: AI forecasts, ranks, explains and drafts; a planner converts, reschedules and cancels, a supervisor releases, a buyer changes supplier dates and a cost accountant settles. Human approval in AI workflows and agent permissions show how to enforce that in code. The capstone exception management platform builds MRP exception triage end to end.

Pitfalls

  • Treating MRP output as a plan the plant can build. It assumes infinite capacity. Check bottlenecks before you promise dates.
  • Reading every message the same way. A cancel message on seal kits and a reschedule-in that touches a customer are not equal. Rank them.
  • Fixing symptoms in the planning run. Wrong lead times, lot sizes and safety stocks produce wrong messages every night. Fix the master data.
  • Planning on unconfirmed supply. A purchase order without a confirmation is a hope. Make confirmations visible to planners, not only buyers.
  • Forgetting maintenance. A machine outage the planner can't see turns into an overload on the day.
  • Accepting variances as one number. Without scrap quantities and reasons in confirmations, nobody can explain the variance at month end.
  • Assuming ECC and S/4HANA plan the same way. MRP Live, embedded PP/DS and event-based production accounting differ, which matters for Calder's CP20.

Exercise

Turn the worklist into a planner's action note and test one fix yourself.

  1. Run python unit1b/plan_to_produce.py --write (Windows: python unit1b\plan_to_produce.py --write). Copy unit1b/calder_mrp_worklist.md to unit1b/calder_mrp_actions.md.
  2. In the copy, add a column Action to the message table. For each line write one sentence: what the owner should do today.
  3. Add a column AI could with one of: rank, explain, predict, extract or nothing. Use the table under Where AI fits to choose.
  4. In plan_to_produce.py, find order 1100019. Change "MfgOrderPlannedStartDate": "2026-11-03" to "2026-10-27" and "MfgOrderPlannedEndDate": "2026-11-09" to "2026-10-30". This is the planner moving the order earlier.
  5. Run python unit1b/plan_to_produce.py and then python unit1b/plan_to_produce.py --capacity. Note how many messages remain, whether the test bench is still overloaded, and the date purchase order 4500000010 is now needed.
  6. Change the two dates back.
  7. In the action note, add a heading Fix tested with three lines: what moving 1100019 solved, what it made worse, and who has to agree before the planner does it.
  8. Run python unit1b/plan_to_produce.py --order 1100016 and add a heading Variance note with two lines a cost accountant could send to the plant manager: the amount and its two causes.
  9. Add a heading ECC note with one line on what a group view of impeller supply needs from CP20. Preview the file and commit it with Git.

Done when: unit1b/calder_mrp_actions.md has the nine-line message table with Action and AI could filled in for every line, a Fix tested section that says the run drops to 8 messages, the test bench overload is gone, and purchase order 4500000010 is now needed on 2026-10-27 (so the motor shortage gets worse), a Variance note naming 4,100.00 USD of scrap and 180.00 USD of extra test time, 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 plan-to-produce part.

Check yourself

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

1Calder's ES-300 is planned with strategy 40. Brennock orders two ES-300 in November, when the PIR is six. How many pumps does MRP plan for November?

Answer: B. With planning with final assembly, sales orders consume the PIRs of their period. The script plans the two ordered pumps plus the four left of the PIR, six in all.

2A new planned order should have started on 22 October, and MRP runs on 26 October. What does SAP do?

Answer: A. When backward scheduling gives a start date in the past, the system switches to forward scheduling. The order arrives late, and message 06 tells the planner why.

3Purchase order 4500000002 gets "reschedule in" in the motor netting. What should happen next?

Answer: B. MRP flags the purchase order but doesn't change it. Only the supplier can deliver earlier, so the buyer chases it; the order is also unconfirmed, so its date is a hope.

4You need production orders with their status in custom code on an S/4HANA cloud edition. What do you read?

Answer: D. The Cloudification Repository lists AFKO as not to be released, with I_ManufacturingOrder as its successor, and both views as released. Released views survive upgrades; table access in custom code doesn't fit clean core.

5The test bench is at 125% in the week of 9 November, yet MRP raised no message about it. Why?

Answer: D. MRP and lead time scheduling don't check other orders' load. The overload shows only in a capacity evaluation, which in the script is the --capacity option.

6Order 1100016 made three good pumps out of four and leaves a 4,280.00 USD variance. What made it explainable?

Answer: C. The confirmation recorded one scrapped pump and the failed pressure test, so the variance splits into scrap and extra test time. Without that, finance sees one number with no cause.

7An agent ranks Calder's MRP messages well. The plant asks it to also move production orders when it is confident. What do you design?

Answer: A. Moving an order changes capacity, components and customer dates. The agent proposes with its evidence; the planner approves in SAP, and the decision is logged with its reason.

8Calder considers PP/DS for the test bench. Which statement belongs in the ADR's consequences?

Answer: C. The decision plans the bench's pumps in PP/DS and leaves components in MRP Live. That needs PP/DS settings on those materials and a finite resource, and planners learn a second tool.

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 5 of 11PlanThe whole stage practices this topic, on Planned demand, MRP run and list and Planned orders.
  • Stage 7 of 11BuildThe whole stage practices this topic, on Planned orders, Production orders, Material plant data and 1 more.
  • Stage 4 of 11Create master dataPractice it on Material plant data, Bills of material, Routings and 1 more.
  • Stage 8 of 11Take ordersPractice it on Material plant data, Bills of material and MRP run and list.
  • Stage 11 of 11Pay and close the monthPractice it on Demand plan.
  • ExercisePlanner's morningOne planning run, five exception messages, one of every kind: rank them, fix each where it is fixed, run planning again and explain what is left.
  • 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.
  • DrillTwelve seal kits for six pumpsProduction, about 6 minutes
  • DrillThe requisition with no supplierPlanning, about 6 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