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.

Maintain to settle: plant maintenance and assets

Follow a breakdown from notification to settled order in SAP, see how plans, parts and failure codes decide maintenance quality, and where AI helps.

Updated Oct 24, 2026Foundational 9 minDeep 40 min
Foundational layer · 9 min read

The 60-second version

Maintain to settle is everything between "this machine needs work" and "the cost of that work is booked where it belongs". In SAP it is the plant maintenance process, now called asset management: a need is reported, a planner turns it into an order, people and parts are lined up, the work is done and recorded, and the order's costs are settled.

Three objects carry it:

  • Technical objects. Functional locations are the places in a plant, arranged as a tree. Equipment is the physical thing installed there: a pump, a crane, a compressor. History and costs collect on them.
  • Notifications. A notification reports a problem or a request. When it is closed, it should say which part failed, how, and why, using standard codes.
  • Orders. A maintenance order plans the work: operations, hours, spare parts and services. It collects actual costs and then settles them, usually to the equipment's cost center, sometimes to a fixed asset when the work adds value.

Preventive work comes from maintenance plans: every twelve months, every 500 operating hours, or when a reading crosses a limit.

Three ideas carry this topic:

  • Parts decide the schedule. A job with no seal kit on the shelf doesn't happen on the planned day, however well it was planned.
  • History is only as good as the coding. A malfunction closed with "other" teaches nobody anything.
  • Every order must end in finance. An order that is finished but never settled leaves costs nowhere, and the asset's real cost of ownership stays unknown.

Why it matters to the business

Maintenance decides whether machines are available when production needs them, how safe the plant is, and how long assets last. Operations watches downtime and the backlog; finance watches maintenance cost per asset and what gets capitalized.

Here is Monday morning, 26 October 2026, at Calder Pumpworks, the fictional pump maker this unit follows. The maintenance planner for the Rockford plant (CP11) sees this:

What the worklist shows What it means Who acts
The annual test bench overhaul on 9 November needs a seal set, and none is in stock The outage that production already planned around may run long Maintenance planner, with the buyer
A pressure transducer for the same job is still only a requisition It can't arrive before the work starts Buyer
The air compressor's vibration crossed its limit four days ago A failure is coming, and nobody has looked at the request yet Maintenance gatekeeper
The crane's brake slips again, eleven days after it was fixed The first repair treated the symptom, not the cause Maintenance supervisor
A finished September job still carries 1,240.00 USD The compressor has no cost center, so the order can't settle Cost accountant, with master data
A test bench leak was closed with damage code "other" The bench has had five leaks this year; the codes show two Maintenance supervisor

The business decision is not "do more preventive maintenance". It is whether parts, readings, codes and settlement are reliable enough that the plan can be trusted, because everything else, including any AI, reads them.

How SAP does it

As of October 2026, SAP S/4HANA covers maintain to settle as standard, under the name SAP S/4HANA Asset Management. SAP's training describes the classic corrective flow: a notification on a technical object, an order with operations and materials, availability and capacity checks before release, goods withdrawals, time confirmations on the order and technical findings on the notification, technical completion, settlement by controlling and business completion (SAP Learning).

S/4HANA adds a phase model with nine phases, from initiation and screening by a maintenance gatekeeper through planning, approval, preparation, scheduling, execution and post execution to completion. SAP's Public Edition training ties it to the reactive, proactive, improvement, and operational and overhead maintenance processes (SAP Learning, Public Edition). The order type decides whether an order runs through the phases (SAP Learning, Public Edition).

Around the core, SAP offers:

  • SAP Service and Asset Manager, a mobile app for technicians that works offline and records time, readings, notifications and parts. It needs its own licence and runs on SAP BTP (SAP Learning).
  • SAP Asset Performance Management (APM) for asset health: it takes sensor data and readings, evaluates rules and posts maintenance notifications into S/4HANA (SAP Learning).

For AI, SAP's training describes order recommendations: when a planner creates an order from a notification, a Get Recommendations button suggests orders from similar past notifications. SAP lists it as of SAP S/4HANA 2025 FPS1, with a trained model in the Personalized Recommendation service on SAP BTP (SAP Learning). SAP's store also lists a Maintenance Planner Agent that suggests backlog and schedule changes and is consumed with AI Units; its page offers beta registration, so check its status and licensing before you plan on it (SAP). Where AI creates value in S/4HANA places these in the wider picture.

The process on one page

flowchart LR
  P[Maintenance plan<br/>or reading] -.-> N
  N[Notification<br/>request] --> S[Screening]
  S --> O[Order<br/>planning, approval]
  O --> R[Parts reserved<br/>or requested]
  R --> E[Execution<br/>parts issued]
  E --> C[Confirmations<br/>time, findings]
  C --> T[Technical<br/>completion]
  T --> ST[Settlement<br/>cost center or asset]
Step Team Document created Posts to finance?
Need is reported Operator, sensor rule, maintenance plan Notification (maintenance request or malfunction report) No
Request is screened Maintenance gatekeeper Accepted notification No
Work is planned and approved Maintenance planner, approver Maintenance order with operations and components No
Parts are lined up Planner, stores, buyer Reservations; purchase requisitions and orders for non-stock parts No (commitments only)
Parts are issued or received Stores Goods issue or goods receipt (material document) Yes: part cost to the order
Work is recorded Technician Time confirmation; damage and cause codes on the notification Yes: labor cost to the order
Work is finished Planner Technical completion No
Period closes Cost accounting Settlement document; business completion Yes: order to cost center, asset or project

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

Questions to ask

  1. How many maintenance orders started late last quarter because a part wasn't there, and how far ahead are parts checked?
  2. What share of closed malfunction reports carry an object part, a damage code and a cause, not "other"?
  3. How much cost sits on technically completed orders that haven't settled, and for how long?
  4. Who decides whether a job is expense or capital, and where is that decision recorded on the order?
  5. Which counters and condition readings drive plans, and how often are they entered?
  6. If an AI tool suggests failure codes or reorders the backlog, who accepts each suggestion, and is that logged?

Common misconceptions

  • "Preventive plans run themselves." A time-based plan does. A counter-based plan is only as current as its last reading.
  • "The failure code is paperwork." It is the only part of the history a report can count. Free text helps the next technician; codes help the reliability engineer.
  • "Technically complete means finished." The work is done, but the costs still sit on the order until settlement moves them.
  • "All maintenance is expense." Work that improves an asset can be settled to it and capitalized. That is an accounting decision, made with finance, not by the technician.
  • "An AI agent can create orders from sensor alerts." It can draft a notification and suggest codes and parts. Accepting the request and releasing work stays with people.

Key terms

All course terms, A to Z, in the glossary

  • Functional location: a place in a plant's technical structure, arranged as a hierarchy, where equipment is installed.
  • Equipment: an individual physical object maintained as a unit, with its own history and costs.
  • Notification: the report of a malfunction or a request for work, with codes for object part, damage and cause.
  • Catalog: a list of standard codes, grouped by type (object parts, damage, causes, activities, tasks).
  • Maintenance order: the plan and cost collector for a piece of maintenance work.
  • Maintenance plan: the rule that creates orders or notifications on a cycle, a counter or a condition.
  • Measuring point and counter: where readings are recorded; a counter adds up, such as operating hours.
  • Technical completion (TECO): the status that says the work is done.
  • Settlement: moving an order's costs to their final receiver, such as a cost center or a fixed asset.

Check yourself

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

1Calder's test bench overhaul is planned in detail, but the seal set isn't in stock. What should the planner do first?

Answer: A. Parts decide the schedule. An order released without its parts stops on the day, so the planner either gets the part in time or moves the work, and tells production.

2The bench had five leaks this year, but reports show two. What went wrong?

Answer: D. Reports count codes, not free text. Leaks closed as "other" disappear from the history, so nobody sees that the bench has a seal problem.

3A finished job still carries 1,240.00 USD a month later. What is the usual cause?

Answer: A. Settlement needs a rule, usually proposed from the equipment's cost center. Without a receiver the order can't settle, and the costs sit on it, invisible to the department that caused them.

4Calder replaces the crane's hoist motor with a larger one. How can that cost end up on the crane's fixed asset?

Answer: C. Settlement decides where costs go. An improvement order can carry a settlement rule to a fixed asset, so the cost is capitalized and depreciated; finance decides whether the work qualifies.

5A counter-based plan should call the bench's 500-hour service, but the last reading is weeks old. What is the risk?

Answer: B. Counter-based plans compute dates from readings. Without recent readings the projected date drifts from reality, and the bench runs past its service interval.

6A vendor pitches an agent that turns sensor alerts straight into released maintenance orders. What should you ask for?

Answer: C. An agent can draft the notification, suggest codes and find parts. Accepting work, committing people and parts, and releasing orders stay with the gatekeeper and planner.

7Calder wants technicians to record time and readings offline at the machine. Which SAP offering fits?

Answer: A. SAP Service and Asset Manager is the mobile app for technicians and works offline. APM evaluates asset health, and planning buckets group work for planners.
Deep layer · 40 min read

Mental model

Think of maintain to settle as a ledger kept for each machine: what happened to it, what was done, and what it cost.

  • The technical ledger lives on the equipment and its notifications. Each malfunction report adds a line: which part, what damage, what cause, what was done, how long the machine was down. Plans and readings add the expected lines: service due at 2,000 hours, inspection due in October.
  • The money ledger lives on the orders. Each order collects labor, parts and services, then settles them to the machine's cost center, to a project, or to the fixed asset itself when the work added value.

Every worklist and every AI idea in this topic reads one of those ledgers. A missing part puts the next entry at risk. A missing code leaves a blank line in the technical ledger. An unsettled order leaves the money ledger open. Most maintenance problems that look like planning problems are, underneath, gaps in one of the two.

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, cost centers), Master data that every module depends on (work centers, materials), Document flow and how logistics becomes finance, Procure to pay end to end (non-stock parts and services) and Plan to produce end to end (the test bench outage). It doesn't repeat them.

Technical objects: functional locations and equipment

A functional location is a node in a hierarchy that represents a technical system, a building or part of one, structured by space, technology or process. Its label follows a structure indicator, which sets the allowed characters and where each level ends; labels can be up to 40 characters (SAP Learning).

Equipment is an individual physical object maintained as its own unit, such as a pump or a motor. It is installed at a functional location if its equipment category and the location allow it, and the usage list records where it has been installed over time. Equipment can also sit under other equipment. Assemblies structure an object further as BOM items rather than as objects of their own, and a maintenance BOM lists the spare parts that matter for maintenance (SAP Learning).

The equipment record's organization data holds the account assignment, such as company code and cost center, and the maintenance planning plant (SAP Learning). In SAP's model of the Equipment API, that includes CostCenter, MasterFixedAsset and FixedAsset, MaintenancePlanningPlant, MainWorkCenter, ABCIndicator, CatalogProfile, FunctionalLocation and SuperordinateEquipment (SAP Cloud SDK model, 2022). The FixedAsset field is the link to asset accounting: the machine the technician sees and the asset finance depreciates are two records joined by that field.

At Calder (fictional), the Rockford plant has this structure. The labels are the course's own:

Functional location Equipment Cost center Fixed asset ABC
CP11-ASY-TB1 test bench position 1 10000101 pump test bench TB-1, with 10000102 flow meter under it CP11-420 (the TEST1 work center's cost center) 30000017 A
CP11-ASY-CRN crane runway 10000103 overhead crane 10 t CP11-410 30000009 A
CP11-UTL-AIR compressed air station 10000104 air compressor C-2 none 30000021 B

The compressor's empty cost center is deliberate. It is the root of one of the exceptions you will find.

Catalogs: how failures become data

Notifications are coded from catalogs. SAP's training names the standard catalog types: B object parts, C damage, 5 causes, A activities and 2 tasks, with R to Z free for your own. Each catalog is split into code groups, each holding codes with a short text. A catalog profile, set up per kind of object such as pumps or motors, picks the code groups a user sees. Profiles are assigned to technical objects and to notification types; if both have one, the technical object's wins (SAP Learning).

The profile is the main lever on coding quality. A technician facing 300 codes picks "other". One facing the eight codes that fit a test bench picks the right one.

Notifications

A notification records a malfunction or a requirement against a technical object (SAP Learning). Its header carries the type, technical object, priority, text, breakdown flag and dates; its items carry the coded findings. In SAP's model of the Maintenance Notification API, the header has NotificationType, TechnicalObject, MaintPriority, NotificationText, MaintenanceObjectIsDown, MalfunctionStartDate and MaintenanceOrder; items have MaintNotifObjPrtCode and MaintNotificationDamageCode with their code groups; causes have MaintNotificationCauseCode (SAP Cloud SDK model, 2022).

With the phase model, a requester creates a maintenance request in the initiation phase, and a maintenance gatekeeper screens it: accept, reject or ask for more information. An accepted request becomes a notification for the planner (SAP Learning, Public Edition). Two shortcuts skip steps: emergency work accepts the request without screening and creates and approves the order in the background, and minor work uses only a notification, with no order (SAP Learning).

Notification types such as maintenance request and malfunction report are configuration; this topic uses M1 and M2 as course labels.

The maintenance order

The order plans the work and collects its costs. SAP's training lists its parts (SAP Learning):

Part Holds Calder example (fictional)
Header Order type, description, dates, priority, reference object, responsible people 4000123 annual test bench overhaul on 10000101, 9 to 11 November
Object list Further technical objects or notifications the same work covers None
Operations Tasks, the work center doing them, planned hours 20 h at MECH1 (mechanics), 6 h at ELEC1 (electricians)
Components Spare parts, stock or non-stock 1 seal set CP-SP-TBSEAL, 2 bearings CP-BRG6310, 1 pressure transducer (non-stock)
Production resources and tools Things used but not consumed, such as tools or trucks None in this scenario
Settlement rule Who bears the cost; proposed from the reference object 100% to cost center CP11-420
Costs Estimated, planned and actual costs by value category Planned 3,768.00 USD

In the phase model, a new order starts in planning. Whether it needs approval is set per order type; approval sets the baseline cost to the planned cost. Approved orders go to preparation, where the planner groups work into planning buckets, levels the load and checks parts and services; then scheduling, where operations are dispatched; then execution (SAP Learning, Public Edition). Phase control codes can block an order from moving to a sub-phase, but they don't stop a technician from reporting time, and they never block goods receipts or service entry sheets.

Order types, their numbering and whether they run the phase model are configuration. This topic uses PM01 corrective, PM02 preventive and PM03 improvement as course labels.

Parts: reservations and purchase requisitions

This is where maintenance meets inventory and procurement (SAP Learning):

  • Stock parts are reserved in the warehouse. Customizing per order type decides whether the reservation counts for planning at once or only at release. Planned withdrawals reference the reservation; unplanned ones reference the order.
  • Non-stock parts are bought for the order. A purchase requisition is created when the order is saved or released, depending on the order type. The purchase order is account-assigned to the order, so its goods receipt charges the order, and invoice differences are posted to it automatically.
  • An availability check runs when components are assigned and automatically at release, but an order can be released without its parts. At release at the latest, reservations become relevant to MRP and requisitions are created.

S/4HANA's availability check for maintenance can hand stock to orders by final due date, priority, ABC indicator and creation time, and a planner can keep a commitment so it isn't reassigned (SAP Learning). In the Maintenance Order API model, each component carries Reservation, MaintOrdOpCompRequiredQuantity, RequirementDate, ReservationIsFinallyIssued, MaintOrdOpCompItemCategory, PurchaseRequisition and DeliveryTimeInDays (SAP Cloud SDK model, 2022).

Confirmations and completion

Technicians record two kinds of facts (SAP Learning):

  • Time confirmations on the order's operations: actual work, start and end, final or partial. Each one charges the order with hours times the work center's activity rate. In the Confirmation API model these are ActualWorkQuantity, IsFinalConfirmation, RemainingWorkQuantity, ActivityType and PersonnelNumber (SAP Cloud SDK model, 2022).
  • Technical findings on the notification: damage and where, cause, activities performed, and the machine's downtime (SAP Learning).

Hours can also come from time sheets. SAP's older documentation describes transferring Cross-Application Time Sheet (CATS) records to plant maintenance as confirmations, with an error pool for records that fail (SAP Help, R/3). Mobile apps post confirmations directly.

Technical completion sets the order to TECO. Requisitions without a purchase order get a deletion flag, open reservations and capacities are closed, and, if no settlement rule exists, the system tries to create one (SAP Learning). With the phase model, the planner can first set Main Work Completed to move the order into post execution (SAP Learning, Public Edition).

A maintenance order can't be a permanent cost object, so its costs are moved to target objects, leaving a zero balance. The settlement rule says where. The usual receiver is the cost center of the reference object; a WBS element or a fixed asset is also possible, as the order type's settlement profile allows. Settlement covers stock issues, non-stock receipts, time confirmations, accepted service entry sheets and overhead. It runs per ledger, usually as a background job. For phase-based order types, an order must be fully settled before business completion; for others, closing first leaves costs that can no longer be settled (SAP Learning, Public Edition). The settlement rule entity in SAP's API model carries SettlementType, AccountAssignmentType, SettlementPercentageRate, CostCenter, WBSElementInternalID, MasterFixedAsset and FixedAsset (SAP Cloud SDK model, 2022).

That is how maintenance reaches asset accounting:

  • Repairs settle to a cost center and become expense in that period.
  • Improvements that finance agrees to capitalize can settle to the fixed asset, which increases its value and its depreciation.
  • Larger capital work usually runs as a project: costs collect on an investment WBS element and settle at month end to an asset under construction (AuC). An AuC isn't depreciated; when the asset is ready, full settlement credits the AuC and debits the final assets (SAP Learning).

Whether a given job is capital or expense is an accounting decision under the company's policy. The system only carries it out.

Maintenance plans

Preventive work comes from maintenance plans. Scheduling, usually a background job, starts plans, recalculates due dates and creates call objects: maintenance orders, notifications or service entry sheets. The call horizon says how early the call is made, as a percentage of the cycle or as days before the planned date; at 80% of a 90-day cycle, the call comes on day 72. Shift factors say how much an early or late completion moves the next date, and the completion requirement holds the next call until the previous one is complete (SAP Learning).

Plans can be time-based, performance-based or strategy plans with several cycles. A performance-based plan computes dates from counter readings and an estimated annual performance, and planned dates are recalculated with each new measurement document (SAP Learning). In the Maintenance Plan API model, a plan has MaintenanceStrategy, CallHorizonPercent, SchedulingDuration, LateCompletionShiftInPercent and PrdcssrCallObjCompltnIsRqd, and each maintenance item names the technical object, task list and order type (SAP Cloud SDK model, 2022).

Measuring points and condition-based maintenance

Measuring points describe a condition at a technical object; counters track wear or use, such as operating hours. Each reading is a measurement document, entered by hand, from mobile apps, by barcode or from a process control system. Damage found during an inspection can trigger a malfunction report automatically (SAP Learning).

For condition-based work, SAP describes two routes. In SAP S/4HANA Cloud, release 2202 added limits and tolerances on measuring points that create a notification with a set type and priority when a reading crosses them (SAP Community, 2022). For richer rules and sensor data, SAP Asset Performance Management takes time-series data and readings, evaluates rules and posts maintenance notifications into S/4HANA; it also replicates failure modes and causes as catalog codes (SAP Learning).

Document flow and postings

flowchart LR
  N[Notification<br/>10000452] --> O[Order 4000121]
  O --> RS[Reservation<br/>seal set]
  RS --> GI[Goods issue]
  GI --> J1[Journal<br/>inventory to order]
  O --> CF[Time confirmation<br/>6.5 h MECH1]
  CF --> J2[Labor cost to order,<br/>maintenance cost<br/>center credited]
  O --> T[TECO]
  T --> ST[Settlement]
  ST --> J3[Journal<br/>order to CP11-420]
Step Posts to finance Why
Notification, order, approval, release Nothing Plans and commitments
Purchase requisition and purchase order for a non-stock part Nothing (a commitment on the order) Money is promised, not spent
Goods issue of a stock part Inventory leaves stock and is charged to the order The part is used up
Goods receipt of a non-stock part Charged straight to the order through the PO's account assignment It was bought for this order
Service entry sheet accepted External service cost to the order The service was performed
Time confirmation Hours times activity rate to the order; the maintenance work center's cost center is credited Labor was spent
Technical completion Nothing A status
Settlement Order credited; cost center, WBS element or fixed asset debited The costs reach their final owner
Asset under construction settled AuC credited, final asset debited A capital project is finished

At Calder (fictional), order 4000121 fixed a test bench leak with one seal set from stock (420.00 USD) and 6.5 hours of a mechanic at 95.00 USD per hour (617.50 USD). At the end of October it settles 1,037.50 USD to cost center CP11-420, the bench's cost center. That is the same cost center that carries the TEST1 work center in Plan to produce end to end, so maintenance cost flows into the test activity rate that production orders pay. Order 4000108, which put a larger hoist motor on the crane in September, settled 9,360.00 USD to fixed asset 30000009 instead. You compute both in Step 6.

Neighbors

Neighbor Gives maintain to settle Takes from maintain to settle When the link fails
Inventory and warehouse Spare part stock, goods issues Reservations Orders released without parts; technicians wait at the counter
Procurement (procure to pay) Purchase orders, confirmations, service entry sheets Purchase requisitions for non-stock parts and services Parts arrive after the outage; service invoices block
Production (plan to produce) Production schedule, windows for downtime Equipment availability, planned outages A planned overhaul collides with a full week, or production plans on a machine that is down
Controlling Activity rates, settlement rules Actual costs by order and object Costs stuck on orders; cost per asset unknown
Asset accounting Fixed asset numbers, AuCs Settled capital costs Improvements expensed, or repairs capitalized
People and time (hire to retire) Technicians, qualifications, time sheets Hours by order Confirmations missing or posted to the wrong order
Quality, health and safety Inspection requirements, permits Inspection results, work clearance Work starts without a permit; audits fail
Sensors and asset health (APM, IoT) Readings, alerts Technical objects, failure codes Alerts nobody screens; models trained on bad codes
Mobile (Service and Asset Manager) Confirmations, readings, findings Orders, technical objects, history Paper notes typed in days later, or never

Where it breaks

Symptom Usual cause Who resolves it What it costs Calder (scenario)
Order 4000123 has no seal set two weeks before the overhaul The last set went to order 4000121; nobody reordered Planner, buyer The three-day bench outage, already squeezing production, may run long
The pressure transducer for 4000123 is still a requisition Requisition created at release, never converted Buyer Earliest delivery 10 November, a day after work starts
Compressor notification 10000454 waits four days Condition alert in the screening queue with no owner on duty Gatekeeper Compressed air for the assembly line at risk
Crane order 4000126 eleven days after 4000119 Brake pads replaced, cause not found Supervisor, reliability engineer Second repair, crane out twice; a safety risk
Bench service plan MP-TB-500H runs on a 24-day-old reading Operating hours not entered Test bench operator The 500-hour service may be missed or done early
Order 4000112 holds 1,240.00 USD a month after TECO Compressor equipment has no cost center, so no settlement rule Cost accountant, master data September's maintenance cost is missing from the utilities budget
Bench leaks coded "other" Catalog profile too broad; closing in a hurry Supervisor History shows two leaks instead of five; the seal problem stays hidden

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 order header table AUFK and reservation table RESB as not to be released, with I_Order and I_ReservationDocumentItem as successors (Cloudification Repository). The OData API names, entities and fields come from SAP's generated API models, published in 2022 (SAP Cloud SDK models, 2022).

Step OData API (A2X) Released CDS views What to read
Technical objects API_FUNCTIONALLOCATION, API_EQUIPMENT I_FunctionalLocation, I_Equipment, I_TechnicalObject Hierarchy, CostCenter, FixedAsset, ABCIndicator, CatalogProfile
Notification API_MAINTNOTIFICATION (functions SetMaintNotifToInProcess, CompleteMaintNotification) I_MaintenanceNotification, I_MaintNotificationItemTP_2, I_MaintNotificationItemCauseTP Type, priority, text, object part, damage and cause codes
Order and operations API_MAINTENANCEORDER (functions such as ReleaseMaintenanceOrder, SetMaintOrdToTechCompleted) I_MaintenanceOrderTP, I_MaintenanceOrderOperationTP, I_MaintenanceOrderPhase, I_MaintenanceOrderStatus Order type, dates, phase and sub-phase codes, planned and actual work
Components API_MAINTENANCEORDER (MaintOrderOpComponent) I_MaintOrderOpComponentTP_2, I_ReservationDocumentItem Reservation, required quantity, requisition, final issue
Confirmation API_MAINTORDERCONFIRMATION I_MaintOrderConfirmationTP Actual work, final flag, remaining work
Settlement rule API_MAINTENANCEORDER (MaintOrderSettlmtDistrRule) I_MaintOrderSettlmtDistrRuleTP Receiver type, cost center, WBS element, fixed asset
Plans API_MAINTENANCEPLAN I_MaintenancePlanBasic, I_MaintenanceItem, I_MaintenancePlanSchedule, I_MaintenanceCall Cycles, call horizon, scheduled calls
Readings Measuring point and measurement document APIs (OData V4) I_MeasuringPoint, I_MeasurementDocument Counter readings, limits, valuation codes
Assets I_FixedAsset The asset the equipment points to

The functions that release, complete or change orders and notifications change SAP data. Use them only in a design where a person triggers the action. For events, check the event catalog on the SAP Business Accelerator Hub; Event-driven agents with SAP events covers how to react to them.

Build it yourself

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

You will write one script that plays the Rockford maintenance planner's Monday on 26 October 2026. It prints the plant's technical structure, schedules three maintenance plans from cycles and counter readings, builds a ranked worklist of maintenance exceptions, traces two orders from notification to settlement, and measures how well failures are coded. An optional step reads maintenance notifications from SAP's sandbox.

flowchart LR
  P[calder_pack/<br/>plants, suppliers] --> S[maintain_to_settle.py]
  SC[Scenario in the script<br/>technical objects, plans,<br/>notifications, orders] --> S
  S --> T[Structure<br/>--tree]
  S --> PL[Plan dates<br/>--plans]
  S --> W[Ranked worklist]
  S --> O[Order trace<br/>--order]
  S --> C[Coding report<br/>--coding]
  S --> M[calder_maintenance_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 maintenance script checks that plant CP11 belongs to company code CP10 and that supplier 20000001 (Orrin Motor Works) is in the pack, because its scenario uses both.

Step 3: Create the script

  1. In VS Code, create a new file, paste the script below, and save it as maintain_to_settle.py inside the unit1b folder.
  2. Read the blocks FUNCTIONAL_LOCATIONS, EQUIPMENT, MEASURING_POINTS, READINGS, NOTIFICATIONS, HISTORY, ORDERS and PLANS first. They are Calder's maintenance scenario written as Python data. Every value is invented. Work centers TEST1 and ASSY1 and their cost centers come from the master data topic, maintenance order 4000123 and its dates from the plan-to-produce topic, and Corvane Lifting Services from the procure-to-pay topic. Notification types, order types, catalog codes, labels and activity rates are the course's own, not SAP values.
"""Maintain to settle at Calder: technical objects, plans, orders, settlement and the planner's worklist.

Calder Pumpworks is a fictional company. The pack (unit1b/calder_pack/) and the
scenario below (technical objects, notifications, orders, plans, readings, costs)
are made up for the course. Field names follow SAP's released APIs where the
script mirrors one (API_EQUIPMENT, API_MAINTNOTIFICATION, API_MAINTENANCEORDER,
API_MAINTENANCEPLAN, measuring point and measurement document APIs), but the
logic is a teaching sketch, not SAP's. Notification types, order types, catalog
codes, structure labels and activity rates are course labels, not SAP values.

Run it from your course folder:
  python unit1b/maintain_to_settle.py                  the maintenance planner's worklist for 2026-10-26
  python unit1b/maintain_to_settle.py --tree           functional locations, equipment and their assets
  python unit1b/maintain_to_settle.py --plans          preventive and counter-based plans: what is due when
  python unit1b/maintain_to_settle.py --order 4000121  trace one order from notification to settlement
  python unit1b/maintain_to_settle.py --coding         how well failures are coded, with a keyword baseline
  python unit1b/maintain_to_settle.py --write          write unit1b/calder_maintenance_worklist.md
  python unit1b/maintain_to_settle.py --sandbox        count maintenance notifications 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 maintenance planner's review
PLANT = "CP11"
PARTS_WINDOW_DAYS = 21       # check parts for orders that start within this many days
REWORK_WINDOW_DAYS = 30      # a new order on the same equipment this soon after completion is rework
STALE_READING_DAYS = 14      # a counter reading older than this makes the plan's dates a guess

# ------------------------------------------------------- technical objects (fictional)
# Functional locations: places in the plant where equipment is installed.
# The labels follow a course structure: plant - area - position.
FUNCTIONAL_LOCATIONS = [
    {"FunctionalLocation": "CP11", "FunctionalLocationName": "Rockford plant", "SuperiorFunctionalLocation": ""},
    {"FunctionalLocation": "CP11-ASY", "FunctionalLocationName": "Assembly and test hall", "SuperiorFunctionalLocation": "CP11"},
    {"FunctionalLocation": "CP11-ASY-TB1", "FunctionalLocationName": "Test bench position 1", "SuperiorFunctionalLocation": "CP11-ASY"},
    {"FunctionalLocation": "CP11-ASY-CRN", "FunctionalLocationName": "Overhead crane runway", "SuperiorFunctionalLocation": "CP11-ASY"},
    {"FunctionalLocation": "CP11-UTL", "FunctionalLocationName": "Utilities", "SuperiorFunctionalLocation": "CP11"},
    {"FunctionalLocation": "CP11-UTL-AIR", "FunctionalLocationName": "Compressed air station", "SuperiorFunctionalLocation": "CP11-UTL"},
]
# Equipment: the physical things that are maintained, after API_EQUIPMENT.
# CostCenter is the default settlement receiver; FixedAsset links to asset accounting.
EQUIPMENT = {
    "10000101": {"EquipmentName": "Pump test bench TB-1", "FunctionalLocation": "CP11-ASY-TB1",
                 "SuperordinateEquipment": "", "CostCenter": "CP11-420", "FixedAsset": "30000017",
                 "ABCIndicator": "A", "CatalogProfile": "PUMPTB", "WorkCenter": "TEST1"},
    "10000102": {"EquipmentName": "Flow meter FM-1", "FunctionalLocation": "CP11-ASY-TB1",
                 "SuperordinateEquipment": "10000101", "CostCenter": "CP11-420", "FixedAsset": "",
                 "ABCIndicator": "A", "CatalogProfile": "PUMPTB", "WorkCenter": "TEST1"},
    "10000103": {"EquipmentName": "Overhead crane 10 t", "FunctionalLocation": "CP11-ASY-CRN",
                 "SuperordinateEquipment": "", "CostCenter": "CP11-410", "FixedAsset": "30000009",
                 "ABCIndicator": "A", "CatalogProfile": "CRANE", "WorkCenter": "ASSY1"},
    "10000104": {"EquipmentName": "Air compressor C-2", "FunctionalLocation": "CP11-UTL-AIR",
                 "SuperordinateEquipment": "", "CostCenter": "", "FixedAsset": "30000021",
                 "ABCIndicator": "B", "CatalogProfile": "COMPR", "WorkCenter": ""},
}
# Measuring points, after the measuring point API. Limits are course values.
MEASURING_POINTS = {
    "MP-101": {"Equipment": "10000101", "Description": "Operating hours", "IsCounter": True, "Unit": "h"},
    "MP-104": {"Equipment": "10000104", "Description": "Vibration velocity", "IsCounter": False, "Unit": "mm/s",
               "UpperLimit": 7.1},
}
# Measurement documents (readings), after the measurement document API.
READINGS = [
    ("MP-101", "2026-09-01", 1720.0), ("MP-101", "2026-09-15", 1776.0), ("MP-101", "2026-10-02", 1840.0),
    ("MP-104", "2026-10-08", 4.6), ("MP-104", "2026-10-15", 5.9), ("MP-104", "2026-10-22", 7.4),
]

# ------------------------------------------------------------ catalog (course codes)
# Catalog types after SAP's training: B object parts, C damage, 5 causes. Codes are invented.
CATALOG = {
    "B": {"BRAKE": "Brake", "SEAL": "Seal", "BEARING": "Bearing", "SENSOR": "Sensor or meter", "MOTOR": "Motor"},
    "C": {"WORN": "Worn", "LEAK": "Leak", "VIBR": "Vibration", "ELEC": "Electrical fault", "OTHER": "Other"},
    "5": {"WEAR": "Normal wear", "CONTAM": "Contamination", "LOOSE": "Loose connection", "AGE": "Aged material"},
}

# ------------------------------------------------------------ notifications
# After API_MAINTNOTIFICATION: header plus the first item's object part, damage and cause codes.
# Types M1 (maintenance request) and M2 (malfunction report) are course labels.
NOTIFICATIONS = [
    {"MaintenanceNotification": "10000451", "NotificationType": "M2", "TechnicalObject": "10000103",
     "CreationDate": "2026-10-05", "NotificationText": "Hoist brake slipping under load, crane stopped",
     "ObjPart": "BRAKE", "Damage": "WORN", "Cause": "WEAR", "MaintenanceOrder": "4000119", "Status": "completed",
     "Origin": "crane operator"},
    {"MaintenanceNotification": "10000452", "NotificationType": "M2", "TechnicalObject": "10000101",
     "CreationDate": "2026-10-14", "NotificationText": "Leak on pressure side, test stopped twice, seals look hard",
     "ObjPart": "", "Damage": "OTHER", "Cause": "", "MaintenanceOrder": "4000121", "Status": "completed",
     "Origin": "test operator"},
    {"MaintenanceNotification": "10000453", "NotificationType": "M2", "TechnicalObject": "10000103",
     "CreationDate": "2026-10-20", "NotificationText": "Brake slipping again on the hoist",
     "ObjPart": "BRAKE", "Damage": "WORN", "Cause": "", "MaintenanceOrder": "4000126", "Status": "in process",
     "Origin": "crane operator"},
    {"MaintenanceNotification": "10000454", "NotificationType": "M2", "TechnicalObject": "10000104",
     "CreationDate": "2026-10-22", "NotificationText": "Vibration above limit at MP-104 (7.4 mm/s)",
     "ObjPart": "", "Damage": "VIBR", "Cause": "", "MaintenanceOrder": "", "Status": "awaiting screening",
     "Origin": "measurement document (condition)"},
    {"MaintenanceNotification": "10000455", "NotificationType": "M1", "TechnicalObject": "10000101",
     "CreationDate": "2026-10-23", "NotificationText": "Request: second test station for ES-300 pumps",
     "ObjPart": "", "Damage": "", "Cause": "", "MaintenanceOrder": "4000131", "Status": "in process",
     "Origin": "production engineer"},
    {"MaintenanceNotification": "10000456", "NotificationType": "M2", "TechnicalObject": "10000102",
     "CreationDate": "2026-10-26", "NotificationText": "Flow readings jump, display flickers",
     "ObjPart": "", "Damage": "", "Cause": "", "MaintenanceOrder": "", "Status": "awaiting screening",
     "Origin": "test operator"},
]
# Completed malfunction reports from the last twelve months, for the coding history (compact).
# (notification, equipment, date, text, object part, damage, cause)
HISTORY = [
    ("10000302", "10000101", "2025-11-18", "Seal leak at outlet flange", "SEAL", "LEAK", "AGE"),
    ("10000317", "10000101", "2026-01-12", "Water on floor under bench", "", "OTHER", ""),
    ("10000329", "10000103", "2026-02-03", "Hoist brake noisy", "BRAKE", "WORN", "WEAR"),
    ("10000344", "10000104", "2026-03-09", "Compressor trips, hot", "MOTOR", "ELEC", "LOOSE"),
    ("10000358", "10000101", "2026-04-21", "Pressure side dripping after test", "", "OTHER", ""),
    ("10000371", "10000102", "2026-05-14", "Flow meter shows zero", "SENSOR", "ELEC", "LOOSE"),
    ("10000386", "10000104", "2026-06-30", "Loud rattle, vibration", "", "OTHER", ""),
    ("10000402", "10000101", "2026-08-11", "Seal leak, pump side", "SEAL", "LEAK", "WEAR"),
    ("10000388", "10000104", "2026-09-21", "Compressor trips again", "MOTOR", "ELEC", "LOOSE"),
    ("10000417", "10000103", "2026-09-02", "Brake slips under load", "BRAKE", "WORN", ""),
]

# ------------------------------------------------------------ orders
# After API_MAINTENANCEORDER: header, operations (work center, planned and actual hours),
# components (stock items are reserved; non-stock items create a purchase requisition)
# and one settlement rule. Order types PM01 (corrective), PM02 (preventive), PM03
# (improvement) are course labels.
RATES = {"MECH1": 95.00, "ELEC1": 105.00}  # USD per hour; maintenance cost center CP11-450 (course values)
STOCK = {"CP-SP-TBSEAL": 0, "CP-SP-BRK10": 3, "CP-BRG6310": 6}
ORDERS = {
    "4000108": {"Type": "PM03", "Equipment": "10000103", "Desc": "Replace hoist motor with 15 kW unit",
                "Notification": "", "Start": "2026-09-14", "Status": "closed", "TECO": "2026-09-17",
                "Ops": [("MECH1", 10.0, 12.0), ("ELEC1", 4.0, 4.0)],
                "Components": [("hoist motor 15 kW (non-stock)", 1, 7800.00, "PO 4500000011 from Orrin Motor Works")],
                "Settlement": ("FXA", "30000009", "capitalized: the crane asset gains a better hoist"),
                "Settled": "2026-09-30"},
    "4000112": {"Type": "PM01", "Equipment": "10000104", "Desc": "Compressor trips: replace contactor",
                "Notification": "10000388", "Start": "2026-09-22", "Status": "technically complete",
                "TECO": "2026-09-24", "Ops": [("ELEC1", 3.0, 4.0)],
                "Components": [("contactor (non-stock)", 1, 820.00, "PO 4500000012 from a local electrical wholesaler")],
                "Settlement": None, "Settled": ""},
    "4000119": {"Type": "PM01", "Equipment": "10000103", "Desc": "Hoist brake slipping: replace brake pads",
                "Notification": "10000451", "Start": "2026-10-06", "Status": "technically complete",
                "TECO": "2026-10-09", "Ops": [("MECH1", 3.0, 3.5)],
                "Components": [("CP-SP-BRK10", 1, 180.00, "stock")],
                "Settlement": ("CTR", "CP11-410", "cost center of the crane"), "Settled": ""},
    "4000121": {"Type": "PM01", "Equipment": "10000101", "Desc": "Test bench leak: replace pressure-side seals",
                "Notification": "10000452", "Start": "2026-10-15", "Status": "technically complete",
                "TECO": "2026-10-16", "Ops": [("MECH1", 4.0, 6.5)],
                "Components": [("CP-SP-TBSEAL", 1, 420.00, "stock")],
                "Settlement": ("CTR", "CP11-420", "cost center of the test bench"), "Settled": ""},
    "4000123": {"Type": "PM02", "Equipment": "10000101", "Desc": "Annual test bench overhaul (plan MP-TB-12M)",
                "Notification": "", "Start": "2026-11-09", "Status": "released, in preparation",
                "TECO": "", "Ops": [("MECH1", 20.0, 0.0), ("ELEC1", 6.0, 0.0)],
                "Components": [("CP-SP-TBSEAL", 1, 420.00, "stock"), ("CP-BRG6310", 2, 64.00, "stock"),
                               ("pressure transducer (non-stock)", 1, 690.00,
                                "purchase requisition 0010000014, no PO yet, delivery 15 days")],
                "Settlement": ("CTR", "CP11-420", "cost center of the test bench"), "Settled": ""},
    "4000126": {"Type": "PM01", "Equipment": "10000103", "Desc": "Hoist brake slipping again",
                "Notification": "10000453", "Start": "2026-10-27", "Status": "released",
                "TECO": "", "Ops": [("MECH1", 3.0, 0.0)],
                "Components": [("CP-SP-BRK10", 1, 180.00, "stock")],
                "Settlement": ("CTR", "CP11-410", "cost center of the crane"), "Settled": ""},
    "4000131": {"Type": "PM03", "Equipment": "10000101", "Desc": "Install second test station",
                "Notification": "10000455", "Start": "2026-12-07", "Status": "submitted for approval",
                "TECO": "", "Ops": [("MECH1", 32.0, 0.0), ("ELEC1", 16.0, 0.0)],
                "Components": [("flow meter and manifold (non-stock)", 1, 3880.00, "requisition after approval")],
                "Settlement": ("FXA", "30000041", "asset under construction: second test station"),
                "Settled": ""},
}
# Events for the order trace (date, step, who, what).
EVENTS = {
    "4000121": [
        ("2026-10-14", "Notification 10000452", "Test operator", "malfunction report on 10000101; damage coded OTHER only"),
        ("2026-10-14", "Order 4000121 (PM01)", "Maintenance planner", "created from the notification; settlement rule proposed from the equipment"),
        ("2026-10-14", "Release", "Maintenance planner", "availability check: 1 seal set in stock, reserved"),
        ("2026-10-15", "Goods issue", "Storekeeper", "1 x CP-SP-TBSEAL against reservation; last one in stock"),
        ("2026-10-15", "Time confirmation", "Mechanic (MECH1)", "6.5 h actual vs 4.0 h planned; final confirmation"),
        ("2026-10-16", "Technical completion", "Maintenance planner", "notification completed with the order; no cause code"),
        ("2026-10-31", "Settlement (period end)", "Cost accountant", "order balance to cost center CP11-420"),
    ],
    "4000108": [
        ("2026-09-08", "Order 4000108 (PM03)", "Maintenance planner", "improvement: larger hoist motor; settlement rule to asset 30000009"),
        ("2026-09-09", "Approval", "Plant manager", "approved as capital spend"),
        ("2026-09-09", "Purchase order 4500000011", "Buyer", "hoist motor from Orrin Motor Works, account assigned to the order"),
        ("2026-09-14", "Goods receipt", "Storekeeper", "motor received; cost goes straight to the order"),
        ("2026-09-16", "Time confirmations", "Mechanic, electrician", "12.0 h MECH1, 4.0 h ELEC1"),
        ("2026-09-17", "Technical completion", "Maintenance planner", "crane back in service"),
        ("2026-09-30", "Settlement (period end)", "Cost accountant", "order balance to fixed asset 30000009"),
        ("2026-09-30", "Business completion", "Maintenance planner", "balance zero; order closed"),
    ],
}

# ------------------------------------------------------------ maintenance plans
# After API_MAINTENANCEPLAN. Time-based plans have a cycle in months; the counter-based plan
# has a cycle in operating hours read from MP-101. CallHorizonPercent: when the order is called.
PLANS = [
    {"MaintenancePlan": "MP-TB-12M", "Desc": "Annual test bench overhaul", "Equipment": "10000101",
     "Kind": "time", "CycleMonths": 12, "LastDone": "2025-11-09", "CallHorizonPercent": 90, "Order": "4000123"},
    {"MaintenancePlan": "MP-CRANE-12M", "Desc": "Annual crane inspection (external, Corvane Lifting Services)",
     "Equipment": "10000103", "Kind": "time", "CycleMonths": 12, "LastDone": "2026-10-14",
     "CallHorizonPercent": 80, "Order": ""},
    {"MaintenancePlan": "MP-TB-500H", "Desc": "Test bench 500-hour service", "Equipment": "10000101",
     "Kind": "counter", "MeasuringPoint": "MP-101", "CycleHours": 500.0, "LastDoneAt": 1500.0,
     "AnnualEstimate": 1000.0, "CallHorizonPercent": 90, "Order": ""},
]

OWNER = {
    "PARTS": "Maintenance planner, with the buyer",
    "COND": "Maintenance gatekeeper (screen the request)",
    "REWORK": "Maintenance supervisor",
    "COUNTER": "Test bench operator (enter the reading)",
    "SETTLE": "Cost accountant, with master data",
    "CODING": "Maintenance supervisor (code it, or send it back)",
}
RANK = ["PARTS", "COND", "REWORK", "COUNTER", "SETTLE", "CODING"]


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


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


def add_months(day: date, months: int) -> date:
    month = day.month - 1 + months
    year = day.year + month // 12
    month = month % 12 + 1
    last = [31, 29 if year % 4 == 0 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]
    return date(year, month, min(day.day, last))


def check_pack() -> None:
    try:
        org = json.loads((PACK / "org_units.json").read_text(encoding="utf-8"))
        partners = json.loads((PACK / "business_partners.json").read_text(encoding="utf-8"))
    except FileNotFoundError:
        sys.exit(f"Cannot read {PACK}. Run: python unit1b/make_calder_pack.py (Unit 1B setup).")
    plants = {p["Plant"]: p["CompanyCode"] for p in org["Plants"]}
    if plants.get(PLANT) != "CP10":
        sys.exit("Plant CP11 of company code CP10 is missing from the pack. Run make_calder_pack.py again.")
    ids = {bp["BusinessPartner"] for bp in partners["BusinessPartners"]}
    if "20000001" not in ids:
        sys.exit("Supplier 20000001 (Orrin Motor Works) is missing from the pack. Run make_calder_pack.py again.")


def order_costs(order: dict) -> list:
    """Actual cost lines: (value category, text, amount)."""
    lines = []
    for wc, _planned, actual in order["Ops"]:
        if actual:
            lines.append(("internal labor", f"{wc} {actual:.1f} h x {money(RATES[wc])}", round(actual * RATES[wc], 2)))
    if order["TECO"] or order["Status"] == "closed":
        for text, qty, price, source in order["Components"]:
            category = "material from stock" if source == "stock" else "external purchase"
            lines.append((category, f"{qty} x {text}", round(qty * price, 2)))
    return lines


def settlement_rule(order: dict):
    """The order's own rule, or one proposed from the equipment's cost center, or None."""
    if order["Settlement"]:
        return order["Settlement"]
    cost_center = EQUIPMENT[order["Equipment"]]["CostCenter"]
    return ("CTR", cost_center, "proposed from the equipment's cost center") if cost_center else None


def balance(order: dict) -> float:
    return round(sum(a for _, _, a in order_costs(order)), 2)


# --------------------------------------------------------------- plans
def plan_dates(plan: dict) -> dict:
    """Next due date and call date for one plan (teaching sketch of SAP's scheduling)."""
    if plan["Kind"] == "time":
        last = d(plan["LastDone"])
        due = add_months(last, plan["CycleMonths"])
        cycle_days = (due - last).days
        call = last + timedelta(days=round(cycle_days * plan["CallHorizonPercent"] / 100))
        return {"due": due, "call": call, "basis": f"every {plan['CycleMonths']} months from {last}"}
    rows = sorted((d(day), val) for mp, day, val in READINGS if mp == plan["MeasuringPoint"])
    (first_day, first_val), (last_day, last_val) = rows[0], rows[-1]
    per_day = (last_val - first_val) / (last_day - first_day).days if last_day > first_day else plan["AnnualEstimate"] / 365
    due_at = plan["LastDoneAt"] + plan["CycleHours"]
    call_at = plan["LastDoneAt"] + plan["CycleHours"] * plan["CallHorizonPercent"] / 100
    due = last_day + timedelta(days=round((due_at - last_val) / per_day))
    call = last_day + timedelta(days=round((call_at - last_val) / per_day))
    return {"due": due, "call": call, "per_day": per_day, "last_day": last_day, "last_val": last_val,
            "due_at": due_at, "call_at": call_at,
            "basis": f"{plan['CycleHours']:.0f} h after {plan['LastDoneAt']:.0f} h, {per_day:.2f} h/day from readings"}


# --------------------------------------------------------------- worklist
def worklist() -> list:
    items = []
    # 1. Parts for orders that start soon
    for no, o in ORDERS.items():
        if o["TECO"] or o["Status"] in ("closed", "submitted for approval"):
            continue
        start = d(o["Start"])
        if (start - AS_OF).days > PARTS_WINDOW_DAYS:
            continue
        for text, qty, _price, source in o["Components"]:
            if source == "stock":
                if STOCK.get(text, 0) < qty:
                    items.append({"kind": "PARTS", "object": f"order {no}", "equipment": o["Equipment"],
                                  "detail": f"{text}: need {qty}, stock {STOCK.get(text, 0)}, no open purchase order; work starts {start}"})
            elif "no PO" in source:
                days = int(source.split("delivery ")[1].split(" ")[0])
                earliest = AS_OF + timedelta(days=days)
                if earliest > start:
                    items.append({"kind": "PARTS", "object": f"order {no}", "equipment": o["Equipment"],
                                  "detail": f"{text}: {source.split(',')[0]} not yet ordered; earliest delivery {earliest}, work starts {start}"})
    # 2. Condition-based notifications waiting
    for n in NOTIFICATIONS:
        if n["Origin"].startswith("measurement") and not n["MaintenanceOrder"]:
            age = (AS_OF - d(n["CreationDate"])).days
            items.append({"kind": "COND", "object": f"notification {n['MaintenanceNotification']}",
                          "equipment": n["TechnicalObject"],
                          "detail": f"{n['NotificationText']}; {n['Status']} for {age} days"})
    # 3. Rework: a new order on the same equipment soon after another was completed
    for no, o in ORDERS.items():
        if o["TECO"] or o["Type"] != "PM01":
            continue
        created = d(next(n["CreationDate"] for n in NOTIFICATIONS if n["MaintenanceNotification"] == o["Notification"]))
        for no2, o2 in ORDERS.items():
            if no2 != no and o2["Equipment"] == o["Equipment"] and o2["TECO"] and o2["Type"] == "PM01":
                gap = (created - d(o2["TECO"])).days
                if 0 <= gap <= REWORK_WINDOW_DAYS:
                    items.append({"kind": "REWORK", "object": f"order {no}", "equipment": o["Equipment"],
                                  "detail": f"{gap} days after order {no2} ({o2['Desc']}) was completed on the same equipment"})
    # 4. Stale counter readings behind a counter-based plan
    for p in PLANS:
        if p["Kind"] != "counter":
            continue
        info = plan_dates(p)
        age = (AS_OF - info["last_day"]).days
        if age > STALE_READING_DAYS:
            items.append({"kind": "COUNTER", "object": f"plan {p['MaintenancePlan']}", "equipment": p["Equipment"],
                          "detail": f"last {p['MeasuringPoint']} reading {info['last_day']} ({age} days old); "
                                    f"projected call {info['call']}, due {info['due']}"})
    # 5. Completed orders that still carry costs
    for no, o in ORDERS.items():
        if o["TECO"] and not o["Settled"] and d(o["TECO"]) < date(AS_OF.year, AS_OF.month, 1):
            reason = ("no settlement rule: equipment " + o["Equipment"] + " has no cost center"
                      if settlement_rule(o) is None else "rule in place, settlement not run")
            items.append({"kind": "SETTLE", "object": f"order {no}", "equipment": o["Equipment"],
                          "detail": f"technically complete {o['TECO']}, {money(balance(o))} USD still on the order; {reason}"})
    # 6. Malfunction reports without damage or cause codes
    for n in NOTIFICATIONS:
        if n["NotificationType"] != "M2" or n["Origin"].startswith("measurement"):
            continue
        # Causes are often found only during the repair, so open reports are checked for damage and part only.
        keys = (("object part", "ObjPart"), ("cause", "Cause")) if n["Status"] == "completed" else (("object part", "ObjPart"),)
        missing = [label for label, key in keys if not n[key]]
        if n["Damage"] in ("", "OTHER"):
            missing.insert(0, "damage")
        if missing:
            items.append({"kind": "CODING", "object": f"notification {n['MaintenanceNotification']}",
                          "equipment": n["TechnicalObject"],
                          "detail": f"\"{n['NotificationText']}\" ({n['Status']}): no {', '.join(missing)}"})
    abc = {e: v["ABCIndicator"] for e, v in EQUIPMENT.items()}
    items.sort(key=lambda r: (RANK.index(r["kind"]), abc.get(r["equipment"], "C"), r["object"]))
    return items


def print_worklist(items: list) -> None:
    print(f"Maintenance worklist for plant {PLANT} as of {AS_OF} (fictional Calder data)\n")
    print(f"  {'#':>2}  {'Kind':<8}{'Object':<24}{'Equipment':<10} Owner")
    for i, it in enumerate(items, 1):
        print(f"  {i:>2}  {it['kind']:<8}{it['object']:<24}{it['equipment']:<10} {OWNER[it['kind']]}")
        print(f"      {'':<8}{it['detail']}")
    open_cost = sum(balance(o) for o in ORDERS.values() if o["TECO"] and not o["Settled"]
                    and d(o["TECO"]) < date(AS_OF.year, AS_OF.month, 1))
    print(f"\n  {len(items)} items; {money(open_cost)} USD on orders completed before this month and not settled.")
    print("  Synthetic data: the list shows the method, not results from an SAP system.")


# --------------------------------------------------------------- views
def print_tree() -> None:
    print(f"Technical objects at {PLANT} (fictional; labels are course values)\n")
    children = {}
    for fl in FUNCTIONAL_LOCATIONS:
        children.setdefault(fl["SuperiorFunctionalLocation"], []).append(fl)

    def show_equipment(fl_id: str, superior: str, depth: int) -> None:
        for no, e in EQUIPMENT.items():
            if e["FunctionalLocation"] == fl_id and e["SuperordinateEquipment"] == superior:
                asset = e["FixedAsset"] or "-"
                cc = e["CostCenter"] or "MISSING"
                mps = ", ".join(m for m, v in MEASURING_POINTS.items() if v["Equipment"] == no)
                print(f"{'  ' * depth}[{no}] {e['EquipmentName']}  ABC {e['ABCIndicator']}, cost center {cc}, "
                      f"asset {asset}" + (f", points {mps}" if mps else ""))
                show_equipment(fl_id, no, depth + 1)

    def walk(parent: str, depth: int) -> None:
        for fl in children.get(parent, []):
            print(f"{'  ' * depth}{fl['FunctionalLocation']}  {fl['FunctionalLocationName']}")
            show_equipment(fl["FunctionalLocation"], "", depth + 1)
            walk(fl["FunctionalLocation"], depth + 1)

    walk("", 1)
    print("\n  Equipment is installed at a functional location; costs settle to its cost center by default.")


def print_plans() -> None:
    print(f"Maintenance plans at {PLANT} as of {AS_OF} (fictional)\n")
    for p in PLANS:
        info = plan_dates(p)
        print(f"  {p['MaintenancePlan']:<13}{p['Desc']}")
        print(f"  {'':<13}basis: {info['basis']}")
        call_note = "called" if info["call"] <= AS_OF else "not yet called"
        order = f", order {p['Order']}" if p["Order"] else ""
        print(f"  {'':<13}call {info['call']} ({call_note}{order}), due {info['due']}")
        if p["Kind"] == "counter":
            print(f"  {'':<13}last reading {info['last_val']:.0f} h on {info['last_day']}; call at {info['call_at']:.0f} h, "
                  f"due at {info['due_at']:.0f} h")
        print()
    print("  Condition monitoring:")
    for mp, v in MEASURING_POINTS.items():
        if v["IsCounter"]:
            continue
        for point, day, val in READINGS:
            if point == mp:
                flag = "  above limit -> notification" if val > v["UpperLimit"] else ""
                print(f"    {mp} {v['Description']} on {v['Equipment']}: {day} {val} {v['Unit']} "
                      f"(limit {v['UpperLimit']}){flag}")
    overhaul = plan_dates(PLANS[0])["due"]
    service = plan_dates(PLANS[2])
    gap = (service["due"] - overhaul).days
    print(f"\n  The 500-hour service falls due {gap} days after the overhaul starts: do both in the same outage.")


def explain_order(no: str) -> None:
    if no not in ORDERS:
        sys.exit(f"Order {no} is not in the scenario. Try one of: {', '.join(ORDERS)}")
    o = ORDERS[no]
    e = EQUIPMENT[o["Equipment"]]
    print(f"Maintenance order {no} ({o['Type']}): {o['Desc']} (fictional)")
    print(f"  equipment {o['Equipment']} {e['EquipmentName']}, status {o['Status']}\n")
    for day, step, who, what in EVENTS.get(no, []):
        print(f"  {day}  {step:<26}{who:<22}{what}")
    if no not in EVENTS:
        print("  (no event trace in the scenario for this order; costs below)")
    lines = order_costs(o)
    print("\nCosts on the order (USD):")
    for cat, text, amount in lines:
        print(f"  {cat:<21}{text:<44}{money(amount):>10}")
    total = balance(o)
    print(f"  {'balance':<21}{'':<44}{money(total):>10}")
    planned = sum(p * RATES[wc] for wc, p, _ in o["Ops"]) + sum(q * pr for _, q, pr, _ in o["Components"])
    print(f"  {'planned':<21}{'':<44}{money(planned):>10}")
    if settlement_rule(o) is None:
        print("\nSettlement: no rule. The equipment has no cost center, so none was proposed.")
        print("  The order can't be settled or closed until someone adds a receiver.")
    else:
        kind, receiver, why = settlement_rule(o)
        label = {"CTR": "cost center", "FXA": "fixed asset"}[kind]
        state = f"settled {o['Settled']}" if o["Settled"] else "to be settled at period end"
        print(f"\nSettlement rule: 100% to {label} {receiver} ({why}); {state}")
        if total:
            print(f"  order {no} credited {money(total)}; {label} {receiver} debited {money(total)}")
        if kind == "FXA":
            print("  The cost becomes part of the asset's value and is depreciated, not expensed this month.")
        else:
            print("  The cost becomes maintenance expense of that cost center in the period it is settled.")
    print("\n  Synthetic numbers for a fictional order, not from an SAP system.")


def coding_report() -> None:
    rows = [(n["MaintenanceNotification"], n["TechnicalObject"], n["CreationDate"], n["NotificationText"],
             n["ObjPart"], n["Damage"], n["Cause"]) for n in NOTIFICATIONS
            if n["NotificationType"] == "M2" and n["Status"] == "completed"]
    rows = HISTORY + rows
    full = [r for r in rows if r[4] and r[5] not in ("", "OTHER") and r[6]]
    print(f"Failure coding on completed malfunction reports at {PLANT}, last 12 months (fictional)\n")
    print(f"  {len(rows)} reports; {len(full)} fully coded (object part, damage, cause) = {len(full) / len(rows):.0%}")
    other = [r for r in rows if r[5] in ("", "OTHER")]
    print(f"  {len(other)} have damage OTHER or none, so history can't count them\n")
    print("  Damage per equipment as the codes tell it:")
    for eq in EQUIPMENT:
        counts = {}
        for r in rows:
            if r[1] == eq:
                counts[r[5] or "none"] = counts.get(r[5] or "none", 0) + 1
        if counts:
            print(f"    {eq} {EQUIPMENT[eq]['EquipmentName']:<22}" + ", ".join(f"{k} {v}" for k, v in sorted(counts.items())))
    print("\n  Keyword baseline for uncoded reports (the bar an AI suggestion has to beat):")
    for r in rows + [(n["MaintenanceNotification"], n["TechnicalObject"], n["CreationDate"], n["NotificationText"],
                      n["ObjPart"], n["Damage"], n["Cause"]) for n in NOTIFICATIONS
                     if n["NotificationType"] == "M2" and n["Status"] != "completed"
                     and not n["Origin"].startswith("measurement")]:
        if r[5] not in ("", "OTHER") and r[4]:
            continue
        part, damage, hits = suggest(r[3])
        print(f"    {r[0]} \"{r[3]}\"")
        print(f"      suggest part {part or '?'}, damage {damage or '?'}  ({hits} keyword hits; cause: ask the technician)")
    print("\n  Synthetic data: the rates show the method, not results from an SAP system.")


KEYWORDS = {
    "SEAL": ("seal", "leak", "drip", "water"), "BRAKE": ("brake",), "BEARING": ("bearing", "rattle"),
    "SENSOR": ("meter", "reading", "display"), "MOTOR": ("motor", "trips", "hot"),
}
DAMAGE_WORDS = {
    "LEAK": ("leak", "drip", "water"), "WORN": ("slip", "worn", "noisy"), "VIBR": ("vibration", "rattle"),
    "ELEC": ("display", "flicker", "trips", "zero"),
}


def suggest(text: str) -> tuple:
    low = text.lower()
    best_part = max(KEYWORDS, key=lambda k: sum(w in low for w in KEYWORDS[k]))
    best_damage = max(DAMAGE_WORDS, key=lambda k: sum(w in low for w in DAMAGE_WORDS[k]))
    part_hits = sum(w in low for w in KEYWORDS[best_part])
    damage_hits = sum(w in low for w in DAMAGE_WORDS[best_damage])
    return (best_part if part_hits else "", best_damage if damage_hits else "", part_hits + damage_hits)


def write_markdown(items: list) -> Path:
    out = HERE / "calder_maintenance_worklist.md"
    lines = [f"# Maintenance worklist, {PLANT}, {AS_OF}", "",
             "Fictional Calder Pumpworks data. Synthetic: it shows the method, not results from an SAP system.", "",
             "```mermaid", "flowchart LR",
             "  N[Notification] --> O[Maintenance order]", "  O --> R[Reservations,<br/>requisitions]",
             "  R --> X[Execution,<br/>confirmations]", "  X --> T[Technical<br/>completion]",
             "  T --> S[Settlement]", "  P[Maintenance plan] --> O", "  M[Measurement<br/>document] --> N", "```", "",
             "| # | Kind | Object | Equipment | Owner | Detail |", "| --- | --- | --- | --- | --- | --- |"]
    for i, it in enumerate(items, 1):
        lines.append(f"| {i} | {it['kind']} | {it['object']} | {it['equipment']} | {OWNER[it['kind']]} | {it['detail'].replace('|', '/')} |")
    out.write_text("\n".join(lines) + "\n", encoding="utf-8")
    return out


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


def sandbox() -> None:
    key = read_env_key("SAP_API_KEY")
    if not key:
        sys.exit("SAP_API_KEY is not set. Add it to .env (course setup) or skip this step.")
    base = "https://sandbox.api.sap.com/s4hanacloud/sap/opu/odata/sap/API_MAINTNOTIFICATION/MaintenanceNotification"
    query = urllib.parse.urlencode({"$top": "50", "$format": "json",
                                    "$select": "MaintenanceNotification,NotificationType,MaintPriority,"
                                               "TechnicalObject,NotificationText,MaintNotificationCodeGroup,"
                                               "MaintNotificationCode,MaintenanceOrder"})
    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", [])
    types, coded, with_order = {}, 0, 0
    for it in items:
        types[it.get("NotificationType", "")] = types.get(it.get("NotificationType", ""), 0) + 1
        coded += 1 if it.get("MaintNotificationCode") else 0
        with_order += 1 if it.get("MaintenanceOrder") else 0
    print(f"SAP sandbox: {len(items)} maintenance notifications read (SAP demo data, not Calder's)\n")
    print("  NotificationType: " + ", ".join(f"'{t or '(empty)'}' x{n}" for t, n in sorted(types.items())))
    print(f"  with a header code: {coded} of {len(items)}; with an order: {with_order} of {len(items)}")
    for it in items[:3]:
        print(f"  e.g. {it.get('MaintenanceNotification')}  {it.get('TechnicalObject')}  {it.get('NotificationText')}")


# --------------------------------------------------------------- main
def main() -> None:
    parser = argparse.ArgumentParser(description="Maintain to settle at Calder (fictional data).")
    parser.add_argument("--tree", action="store_true", help="functional locations, equipment and assets")
    parser.add_argument("--plans", action="store_true", help="maintenance plans and condition readings")
    parser.add_argument("--order", help="trace one maintenance order, e.g. 4000121")
    parser.add_argument("--coding", action="store_true", help="failure coding quality and a keyword baseline")
    parser.add_argument("--write", action="store_true", help="write unit1b/calder_maintenance_worklist.md")
    parser.add_argument("--sandbox", action="store_true", help="count maintenance notifications in SAP's sandbox")
    args = parser.parse_args()
    if args.sandbox:
        sandbox()
        return
    check_pack()
    if args.tree:
        print_tree()
    elif args.plans:
        print_plans()
    elif args.order:
        explain_order(args.order)
    elif args.coding:
        coding_report()
    else:
        items = worklist()
        print_worklist(items)
        if args.write:
            print(f"\nwrote unit1b/{write_markdown(items).name}")


if __name__ == "__main__":
    main()

Step 4: Look at the plant's structure

  1. Run:

    • Windows (PowerShell):

      python unit1b\maintain_to_settle.py --tree
    • macOS / Linux:

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

Technical objects at CP11 (fictional; labels are course values)

  CP11  Rockford plant
    CP11-ASY  Assembly and test hall
      CP11-ASY-TB1  Test bench position 1
        [10000101] Pump test bench TB-1  ABC A, cost center CP11-420, asset 30000017, points MP-101
          [10000102] Flow meter FM-1  ABC A, cost center CP11-420, asset -
      CP11-ASY-CRN  Overhead crane runway
        [10000103] Overhead crane 10 t  ABC A, cost center CP11-410, asset 30000009
    CP11-UTL  Utilities
      CP11-UTL-AIR  Compressed air station
        [10000104] Air compressor C-2  ABC B, cost center MISSING, asset 30000021, points MP-104

  Equipment is installed at a functional location; costs settle to its cost center by default.

Read it as a planner would. The flow meter sits under the test bench, so its history rolls up to the bench. Every machine except the compressor has a cost center, which will be the default receiver when its orders settle. Three machines point to fixed assets; the flow meter doesn't, because it was bought as part of the bench.

Step 5: Schedule the maintenance plans

  1. Run:

    • Windows (PowerShell):

      python unit1b\maintain_to_settle.py --plans
    • macOS / Linux:

      python unit1b/maintain_to_settle.py --plans
  2. You should see:

Maintenance plans at CP11 as of 2026-10-26 (fictional)

  MP-TB-12M    Annual test bench overhaul
               basis: every 12 months from 2025-11-09
               call 2026-10-03 (called, order 4000123), due 2026-11-09

  MP-CRANE-12M Annual crane inspection (external, Corvane Lifting Services)
               basis: every 12 months from 2026-10-14
               call 2027-08-02 (not yet called), due 2027-10-14

  MP-TB-500H   Test bench 500-hour service
               basis: 500 h after 1500 h, 3.87 h/day from readings
               call 2026-10-30 (not yet called), due 2026-11-12
               last reading 1840 h on 2026-10-02; call at 1950 h, due at 2000 h

  Condition monitoring:
    MP-104 Vibration velocity on 10000104: 2026-10-08 4.6 mm/s (limit 7.1)
    MP-104 Vibration velocity on 10000104: 2026-10-15 5.9 mm/s (limit 7.1)
    MP-104 Vibration velocity on 10000104: 2026-10-22 7.4 mm/s (limit 7.1)  above limit -> notification

  The 500-hour service falls due 3 days after the overhaul starts: do both in the same outage.

How to read it:

  • The annual overhaul was last done on 9 November 2025, so it falls due on 9 November 2026. At a 90% call horizon, the plan called order 4000123 on 3 October. That is the outage the production planner saw in Plan to produce end to end.
  • The 500-hour service is counter-based. The bench ran 120 hours between 1 September and 2 October, 3.87 hours a day. At that rate it reaches 2,000 hours on 12 November, three days after the overhaul starts. Doing both in the same outage saves a second stop of the bottleneck.
  • That projection rests on a reading from 2 October. If the bench ran harder in October, the service is due earlier. Step 6 flags it.
  • The compressor's vibration rose from 4.6 to 7.4 mm/s in two weeks and crossed its limit, which created notification 10000454. The script's limit logic is a sketch of the threshold idea, not SAP's configuration.

Step 6: Read the planner's worklist and trace two orders

  1. Run the script without options:

    • Windows (PowerShell):

      python unit1b\maintain_to_settle.py
    • macOS / Linux:

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

Maintenance worklist for plant CP11 as of 2026-10-26 (fictional Calder data)

   #  Kind    Object                  Equipment  Owner
   1  PARTS   order 4000123           10000101   Maintenance planner, with the buyer
              CP-SP-TBSEAL: need 1, stock 0, no open purchase order; work starts 2026-11-09
   2  PARTS   order 4000123           10000101   Maintenance planner, with the buyer
              pressure transducer (non-stock): purchase requisition 0010000014 not yet ordered; earliest delivery 2026-11-10, work starts 2026-11-09
   3  COND    notification 10000454   10000104   Maintenance gatekeeper (screen the request)
              Vibration above limit at MP-104 (7.4 mm/s); awaiting screening for 4 days
   4  REWORK  order 4000126           10000103   Maintenance supervisor
              11 days after order 4000119 (Hoist brake slipping: replace brake pads) was completed on the same equipment
   5  COUNTER plan MP-TB-500H         10000101   Test bench operator (enter the reading)
              last MP-101 reading 2026-10-02 (24 days old); projected call 2026-10-30, due 2026-11-12
   6  SETTLE  order 4000112           10000104   Cost accountant, with master data
              technically complete 2026-09-24, 1,240.00 USD still on the order; no settlement rule: equipment 10000104 has no cost center
   7  CODING  notification 10000452   10000101   Maintenance supervisor (code it, or send it back)
              "Leak on pressure side, test stopped twice, seals look hard" (completed): no damage, object part, cause
   8  CODING  notification 10000456   10000102   Maintenance supervisor (code it, or send it back)
              "Flow readings jump, display flickers" (awaiting screening): no damage, object part

  8 items; 1,240.00 USD on orders completed before this month and not settled.
  Synthetic data: the list shows the method, not results from an SAP system.

How to read it:

  • Items 1 and 2 are one problem: the overhaul on 9 November can't start with its parts. The last seal set went into the leak repair on 15 October, and the transducer's requisition was never turned into a purchase order. Production has already planned around a three-day outage; a fourth day costs pumps.
  • Item 3 is a condition alert waiting for a person. The machine told the system; the system is waiting for the gatekeeper.
  • Item 4 is rework. Eleven days after new brake pads, the brake slips again. The cause field on the first notification was left blank, which is why nobody asked why the pads wore.
  • Item 5 comes from Step 5: a plan running on an old reading.
  • Item 6 is money. The September contactor job can't settle, because the compressor has no cost center to propose a rule from.
  • Items 7 and 8 are coding gaps. Open notifications are checked for damage and object part only, because the cause is often found during the repair.
  • The ranking is the script's own: parts for work starting soon first, then condition alerts, rework, readings, settlement and coding, and within each the ABC indicator. A plant chooses its own order.
  1. Trace the leak repair:

    • Windows (PowerShell):

      python unit1b\maintain_to_settle.py --order 4000121
    • macOS / Linux:

      python unit1b/maintain_to_settle.py --order 4000121
  2. You should see:

Maintenance order 4000121 (PM01): Test bench leak: replace pressure-side seals (fictional)
  equipment 10000101 Pump test bench TB-1, status technically complete

  2026-10-14  Notification 10000452     Test operator         malfunction report on 10000101; damage coded OTHER only
  2026-10-14  Order 4000121 (PM01)      Maintenance planner   created from the notification; settlement rule proposed from the equipment
  2026-10-14  Release                   Maintenance planner   availability check: 1 seal set in stock, reserved
  2026-10-15  Goods issue               Storekeeper           1 x CP-SP-TBSEAL against reservation; last one in stock
  2026-10-15  Time confirmation         Mechanic (MECH1)      6.5 h actual vs 4.0 h planned; final confirmation
  2026-10-16  Technical completion      Maintenance planner   notification completed with the order; no cause code
  2026-10-31  Settlement (period end)   Cost accountant       order balance to cost center CP11-420

Costs on the order (USD):
  internal labor       MECH1 6.5 h x 95.00                             617.50
  material from stock  1 x CP-SP-TBSEAL                                420.00
  balance                                                            1,037.50
  planned                                                              800.00

Settlement rule: 100% to cost center CP11-420 (cost center of the test bench); to be settled at period end
  order 4000121 credited 1,037.50; cost center CP11-420 debited 1,037.50
  The cost becomes maintenance expense of that cost center in the period it is settled.

  Synthetic numbers for a fictional order, not from an SAP system.
  1. Now the capital job, --order 4000108:
Maintenance order 4000108 (PM03): Replace hoist motor with 15 kW unit (fictional)
  equipment 10000103 Overhead crane 10 t, status closed

  2026-09-08  Order 4000108 (PM03)      Maintenance planner   improvement: larger hoist motor; settlement rule to asset 30000009
  2026-09-09  Approval                  Plant manager         approved as capital spend
  2026-09-09  Purchase order 4500000011 Buyer                 hoist motor from Orrin Motor Works, account assigned to the order
  2026-09-14  Goods receipt             Storekeeper           motor received; cost goes straight to the order
  2026-09-16  Time confirmations        Mechanic, electrician 12.0 h MECH1, 4.0 h ELEC1
  2026-09-17  Technical completion      Maintenance planner   crane back in service
  2026-09-30  Settlement (period end)   Cost accountant       order balance to fixed asset 30000009
  2026-09-30  Business completion       Maintenance planner   balance zero; order closed

Costs on the order (USD):
  internal labor       MECH1 12.0 h x 95.00                          1,140.00
  internal labor       ELEC1 4.0 h x 105.00                            420.00
  external purchase    1 x hoist motor 15 kW (non-stock)             7,800.00
  balance                                                            9,360.00
  planned                                                            9,170.00

Settlement rule: 100% to fixed asset 30000009 (capitalized: the crane asset gains a better hoist); settled 2026-09-30
  order 4000108 credited 9,360.00; fixed asset 30000009 debited 9,360.00
  The cost becomes part of the asset's value and is depreciated, not expensed this month.

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

The two orders do the same kind of work: a mechanic, parts, a few hours. Settlement is what makes them different. The leak repair becomes expense on the test bench's cost center this period. The hoist motor becomes part of the crane asset and is spread over its remaining life as depreciation. Try --order 4000112 to see the order that can't settle.

Step 7: Measure failure coding and write the worklist file

  1. Run:

    • Windows (PowerShell):

      python unit1b\maintain_to_settle.py --coding
    • macOS / Linux:

      python unit1b/maintain_to_settle.py --coding
  2. You should see:

Failure coding on completed malfunction reports at CP11, last 12 months (fictional)

  12 reports; 7 fully coded (object part, damage, cause) = 58%
  4 have damage OTHER or none, so history can't count them

  Damage per equipment as the codes tell it:
    10000101 Pump test bench TB-1  LEAK 2, OTHER 3
    10000102 Flow meter FM-1       ELEC 1
    10000103 Overhead crane 10 t   WORN 3
    10000104 Air compressor C-2    ELEC 2, OTHER 1

  Keyword baseline for uncoded reports (the bar an AI suggestion has to beat):
    10000317 "Water on floor under bench"
      suggest part SEAL, damage LEAK  (2 keyword hits; cause: ask the technician)
    10000358 "Pressure side dripping after test"
      suggest part SEAL, damage LEAK  (2 keyword hits; cause: ask the technician)
    10000386 "Loud rattle, vibration"
      suggest part BEARING, damage VIBR  (3 keyword hits; cause: ask the technician)
    10000452 "Leak on pressure side, test stopped twice, seals look hard"
      suggest part SEAL, damage LEAK  (3 keyword hits; cause: ask the technician)
    10000456 "Flow readings jump, display flickers"
      suggest part SENSOR, damage ELEC  (4 keyword hits; cause: ask the technician)

  Synthetic data: the rates show the method, not results from an SAP system.

Only 58% of Calder's closed malfunction reports are fully coded, and the gap isn't random: the test bench's leaks are the ones coded "other". Its history reads "LEAK 2, OTHER 3", and every one of the three "other" texts mentions water, dripping or a leak. A reliability engineer reading only the codes would underestimate the bench's seal problem by more than half.

The keyword baseline guesses object part and damage from the text. It gets these five right because the scenario is small; on real notifications, with abbreviations, other languages and typos, it fails often. It never guesses the cause, which needs the technician. That is the bar an AI suggestion has to beat, and the reason to keep the baseline: classification metrics only mean something against it.

  1. Write the worklist file:

    • Windows (PowerShell):

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

      python unit1b/maintain_to_settle.py --write
  2. The last line should read wrote unit1b/calder_maintenance_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 and the eight items with owners.

Step 8 (optional): Read maintenance notifications 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\maintain_to_settle.py --sandbox
    • macOS / Linux:

      python unit1b/maintain_to_settle.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> maintenance notifications read (SAP demo data, not Calder's)

  NotificationType: '<type>' x<count>
  with a header code: <count> of <n>; with an order: <count> of <n>
  e.g. <notification>  <technical object>  <text>

The script reads up to 50 notifications from API_MAINTNOTIFICATION with $select. A valid "empty" result is 0 maintenance notifications read: the sandbox simply has none at that moment. The sandbox is read-only.

Step 9: Save your work in Git

git add unit1b/maintain_to_settle.py unit1b/calder_maintenance_worklist.md
git commit -m "Unit 1B: maintain-to-settle worklist, plans and settlement trace for Calder"

What each part of the code does

Part What it does
FUNCTIONAL_LOCATIONS, EQUIPMENT The plant's technical structure, after the functional location and equipment APIs, with cost center, fixed asset, ABC indicator and catalog profile
MEASURING_POINTS, READINGS One counter (operating hours) and one condition point (vibration), with their measurement documents
CATALOG, NOTIFICATIONS, HISTORY Course codes for object part, damage and cause; open and closed malfunction reports and requests
ORDERS, RATES, STOCK Maintenance orders with operations, components and settlement rules; activity rates; spare part stock
PLANS, plan_dates() Time-based and counter-based plans; due and call dates from the cycle, the call horizon and the reading rate
worklist() Finds missing parts, waiting condition alerts, rework, stale readings, unsettled orders and coding gaps, and ranks them
settlement_rule(), order_costs() The order's rule or one proposed from the equipment's cost center; actual costs by value category
explain_order(), --order The order's events, costs and where settlement sends them
coding_report(), suggest() Coding rate, damage history per machine and a keyword baseline for uncoded texts
--tree, --plans, --write The structure, the plan dates and the worklist as a Markdown file
sandbox() Reads up to 50 sandbox notifications with $select and counts types, codes and 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
Plant CP11 ... is missing or Supplier 20000001 ... is missing The pack was edited Run Step 2 again
Your output differs from the one shown Dates, readings or quantities in the script were changed Copy the script again, or compare your edits
Order ... is not in the scenario You asked for an order the script doesn't have Use one of the numbers the message lists
SAP_API_KEY is not set No key in the environment or .env Add it as in the course setup, or skip Step 8
Sandbox answered HTTP 401 The key is wrong or expired Copy the key again from the Business Accelerator Hub
Sandbox answered HTTP 404 The API or entity isn't in the sandbox under that path Check the API's sandbox URL on the Business Accelerator Hub, or skip Step 8
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), maintenance for Rockford (CP11) runs in the CP10 S/4HANA system: technical objects, notifications, orders, plans and settlement. Technicians would use a mobile app against it. Sensor data from the test bench and compressor would go to an asset health service, which posts notifications back. Augsburg's machining centers are maintained on ECC. Anything that ranks, suggests or summarizes reads released interfaces and writes only drafts and worklists; people accept requests and release orders in SAP's own apps.

flowchart LR
  SEN[Sensors<br/>bench, compressor] -->|time series| APM[Asset health<br/>rules]
  APM -->|notifications| S4[S/4HANA CP10<br/>asset management,<br/>MM, CO, asset accounting]
  MOB[Mobile app<br/>technicians] <-->|orders, confirmations,<br/>readings| S4
  S4 <-->|outages, availability| PP[Production<br/>planning]
  ECC[ECC CP20<br/>Augsburg machines] -.->|history| DW[Data warehouse]
  S4 -->|released APIs, CDS| AI[Triage and coding<br/>assistant, read-only]
  AI --> D[Drafts and<br/>worklists]
  D -->|planner accepts in SAP| S4

Non-functional requirements that decide the design

Requirement Typical target If missed
Alert latency A condition alert reaches a screening queue within minutes, with an owner on every shift Failures happen while the request waits
Offline work Technicians can confirm and read meters with no network, synced later Paper notes, late or lost confirmations
Data volume Readings filtered or aggregated before they become measurement documents Millions of documents nobody reads; slow plans
Master data quality Every equipment record has a cost center, a catalog profile and an ABC indicator Orders can't settle; coding fails; priorities are guesses
Period-end close All technically completed orders settled within the close calendar Costs missing from cost centers and assets
Security and safety Only authorized roles release orders and change plans; permits enforced Unsafe work; audit findings

Integration and identity. SAP APM reads technical objects and posts notifications in batch over OData, and needs near-real-time events for measurement documents when rules depend on them, with time series coming through its IoT services (SAP Learning). SAP Service and Asset Manager runs on SAP BTP Mobile Services (SAP Learning). An assistant that suggests codes or ranks the backlog should read with the planner's identity, so plant and planner group authorizations 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 wants to catch failing machines early on the test bench and compressor. Both are cheap to instrument. Sensors would produce a reading every second; SAP plans need a few readings a day. The bench is the plant's bottleneck.
  • Options: (a) enter readings by hand on rounds and use limits on measuring points in S/4HANA; (b) stream sensor data to an asset health service that evaluates rules and posts notifications, sending only daily summaries into S/4HANA as measurement documents; (c) write every sensor value into S/4HANA as a measurement document.
  • Decision: (b). Raw signals stay outside the ERP; S/4HANA gets what a planner or a plan needs: a counter value per day and a notification when a rule fires.
  • Consequences: A second system to run and license. A defined data boundary: which signals become readings, how often, and who owns the rules. Notifications from rules still go through screening, so a person accepts each one. Failure codes must be shared between the rules and S/4HANA catalogs.

What a design review will ask

  1. Where is the boundary between sensor data and SAP measurement documents, and who owns the rules that cross it?
  2. How is the functional location hierarchy governed, and who may change it?
  3. How do maintenance outages reach production capacity, and how far ahead?
  4. Can any automated component release an order, change a plan or post a confirmation without a person?
  5. How will you measure success: unplanned downtime, schedule compliance, rework rate, coding rate, orders settled on time?

Where AI fits, and where it doesn't

Step Pattern Fit Note
Reporting a problem Extract Strong Turn a spoken or typed description into a draft notification with technical object, priority and text; structured outputs keep the result in SAP's fields
Failure coding Predict (classify) and retrieve Strong Suggest object part, damage and cause codes from the technician's text and similar past cases; the technician confirms. Measure against the keyword baseline from Step 7
Screening Retrieve and explain Moderate Find duplicates and similar open requests; the gatekeeper decides
Order planning Retrieve Strong Suggest operations, parts and hours from similar past orders; SAP lists order recommendations as of S/4HANA 2025 FPS1 (SAP Learning)
Spare part demand Predict Strong Forecast part usage from plans and failure history, as in time-series forecasting, so the seal set is reordered before the overhaul
Backlog and scheduling Rank and explain Moderate Rank work by risk and production impact; SAP lists a Maintenance Planner Agent for this, consumed with AI Units (SAP)
Condition monitoring Detect anomalies Strong Find unusual readings before a limit is crossed; see anomaly detection
Work order summaries Summarize Strong Summarize a machine's history and open work for the technician before the job, citing the notifications
Time confirmation Plain automation is enough Weak for AI Mobile apps and time sheets; no prediction needed
Settlement Plain automation is enough Weak for AI Rules on master data; AI can flag orders that can't settle, not choose receivers
Capital or expense Retrieve and explain Weak for AI to decide An accounting decision under policy; AI can gather the facts for finance

The line is the same at every step: AI drafts, suggests, ranks and summarizes; a technician confirms codes, a gatekeeper accepts requests, a planner releases orders, and a cost accountant decides what is capital. Human approval in AI workflows and agent permissions show how to enforce that in code. One of the portfolio projects, maintenance notification triage, builds the code, retrieve and rank steps end to end.

Pitfalls

  • Checking parts on the day. Run the availability check when the order enters preparation, and again a week before work starts.
  • Equipment without a cost center. Orders can't settle, and maintenance cost disappears from budgets until someone finds it.
  • A catalog profile with hundreds of codes. Technicians pick "other". Keep profiles short and specific to the object type.
  • Counter-based plans without readings. The plan's dates drift. Make the reading part of a routine, or get it from a sensor.
  • Treating rework as a new failure. Look for a repeat within weeks on the same object, and ask why the first repair didn't hold.
  • Letting technicians decide capital or expense. It is finance's call, recorded in the settlement rule.
  • Streaming raw sensor data into the ERP. Decide the boundary first; S/4HANA needs readings and notifications, not every second of a signal.
  • Assuming ECC and S/4HANA work the same way. Screening, the phase model and many apps exist only in S/4HANA; a group view across Calder's two systems must allow for that.

Exercise

Turn the worklist into a planner's action note and test two fixes yourself.

  1. Run python unit1b/maintain_to_settle.py --write (Windows: python unit1b\maintain_to_settle.py --write). Copy unit1b/calder_maintenance_worklist.md to unit1b/calder_maintenance_actions.md.
  2. In the copy, add a column Action to the table. For each line write one sentence: what the owner should do today.
  3. Add a column AI could with one of: extract, suggest codes, predict, rank, summarize or nothing. Use the table under Where AI fits to choose.
  4. In maintain_to_settle.py, find equipment 10000104. Change "CostCenter": "" to "CostCenter": "CP11-460". This is master data adding the compressor to a utilities cost center (a course value).
  5. In READINGS, add ("MP-101", "2026-10-26", 1905.0), after the reading of 2 October. This is the test bench operator entering today's operating hours.
  6. Run the script without options, then with --plans and with --order 4000112. Note how many items remain, what item 6 now says, and the new call and due dates of MP-TB-500H.
  7. Change both edits back.
  8. In the action note, add a heading Fixes tested with three lines: what the cost center fixed, what the reading changed, and whether the 500-hour service still fits the overhaul outage.
  9. Add a heading ECC note with one line on what a group failure history needs from CP20. Preview the file and commit it with Git.

Done when: unit1b/calder_maintenance_actions.md has the eight-line table with Action and AI could filled in for every line, a Fixes tested section that says the list drops to 7 items, the settlement item now reads "rule in place, settlement not run" for cost center CP11-460, and the 500-hour service now calls on 2026-11-08 and falls due on 2026-11-23 (so doing it in the 9 to 11 November outage means doing it early), 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 maintenance part.

Check yourself

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

1Calder's test bench is installed at CP11-ASY-TB1, and its flow meter sits under it. Why model the flow meter as equipment under the bench?

Answer: C. Subordinate equipment keeps its own history and can be swapped, while reports on the bench include it. Functional locations are the places; equipment is what gets installed and replaced.

2A technician faces a catalog with three hundred damage codes and picks "other". What fixes it at the source?

Answer: A. Catalog profiles pick the code groups a user sees for a kind of object. A short, relevant list makes the right code easy; blocking or reminders don't make it findable.

3Order 4000123 needs a non-stock pressure transducer. What happens to it in SAP?

Answer: B. Non-stock parts are bought for the order. The requisition is created at save or release, depending on the order type, and the goods receipt on the account-assigned PO charges the order.

4Order 4000112 is technically complete, but its 1,240.00 USD won't settle. What do you check first?

Answer: D. The settlement rule is usually proposed from the reference object's cost center. With none on the equipment, there is no receiver, and the costs sit on the order.

5The 500-hour plan projects its due date from a reading 24 days old. A new reading shows the bench ran less than expected. What happens?

Answer: B. Performance-based plans recalculate planned dates with each measurement document. Fewer hours per day push the call and due dates later, which is what the exercise shows.

6Calder streams vibration data every second from the compressor. Where should those values live?

Answer: B. The ERP needs readings that plans use and notifications when a rule fires, not every value. Keeping raw signals outside and defining the boundary is the decision in the ADR.

7You build an assistant that suggests failure codes from a technician's text. How do you know it helps?

Answer: C. A suggestion is worth having only if it is more accurate than the simple rule, measured on cases with known codes. The technician still confirms, because the cause often needs someone who saw the machine.

8An agent proposes to release orders straight from sensor alerts to save the gatekeeper time. What do you design?

Answer: A. Accepting work commits people, parts and an outage. The agent drafts and explains; the gatekeeper and planner decide in SAP, and the decision is logged.

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.

  • 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.

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