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.

Project: late delivery predictor

Turn the Unit 2 late-delivery model into a live planner worklist with an API, approvals, monitoring, a retraining plan and a KPI measured against a holdout.

Updated Oct 23, 2026Foundational 9 minDeep 60 min
Foundational layer · 9 min read

The business problem

Every day a warehouse ships hundreds of outbound deliveries. Each one has a planned goods issue date, the day the goods should leave. Some leave late. A late goods issue can mean a missed truck, a missed delivery window, a penalty or revenue that slips into next month.

Today, most late deliveries are discovered when they are already late. A planner sees the overdue list, phones the warehouse and pays for an express carrier. That is reactive expediting: expensive, and often too late to help the customer.

Who feels it: shipping planners and warehouse leads (order-to-cash), customer service, who take the calls, and finance, who pays the express freight and sees revenue slip.

Today's numbers must come from your own system. This project uses made-up, SAP-shaped data: about 86% of deliveries leave on time, and a late delivery that nobody saw coming costs about 250 EUR in penalties and express freight. Acting on a warning early, a check plus a cheaper fix such as an earlier truck, is assumed to cost 50 EUR. Those costs come from the metric contract in Classification and the metrics that matter; yours will differ.

In Predicting late deliveries with SAP data you built a model that ranks open deliveries by risk. A model on a laptop helps nobody. This project turns it into a small product that planners use every morning, that someone owns, and that proves its value in euros.

What the solution does

A day in the life after go-live:

  1. 7:45. Omar, the planner for shipping point 1710, opens the worklist. It shows his open deliveries only, ranked by risk. Four carry a CHECK flag, with the two strongest reasons, such as "route R17103, shipping point 1710". One is marked OVERDUE: its planned date has passed.
  2. 7:50. He moves the riskiest one to an earlier truck in SAP, using his usual app, and clicks Expedite in the worklist with a note. The system records the decision and the 50 EUR cost. It never changes the delivery in SAP itself.
  3. 8:00. Pia, who plans shipping points 1010 and 1020, sees only her deliveries. She can't decide for Omar's.
  4. 9:30. Vic, a sales manager, opens the same page to answer a customer. He can see risks, but he can't record decisions.
  5. Monday. The model owner runs the weekly monitoring check. It compares recent weeks with what the model learned. When a route suddenly gets worse, or a new route appears, it says RETRAIN RECOMMENDED.
  6. End of month. A new model is trained on the newest outcomes. It goes live only when a second person approves it, and the old one stays ready for a rollback.

The KPI

A KPI that a business owner can sign names a metric, a baseline, a target and a method. Defining success metrics with the business explains how to agree one.

On-time goods issue Expedite spend per late delivery avoided
Metric Share of deliveries whose goods issue was posted on or before the planned date Expedite spend divided by late deliveries the worklist prevented
Baseline 86% (made-up sample; measure yours over the last quarter) Today's reactive express freight per late delivery
Target Agree it, for example +3 points in two quarters Below the cost of a late delivery (250 EUR in the sample), or expediting doesn't pay
How it is measured Outcomes from SAP joined to the app's own log The app's decision log, compared with a holdout
Guardrail Flags per week stay within what planners agreed to handle Planners act on most flags; if they ignore them, find out why

The holdout is the important idea. A fixed 20% of deliveries is scored but never flagged. Comparing flagged deliveries that planners saw with flagged deliveries they didn't is a fair test of the worklist. A plain before-and-after comparison is not. In this project's simulation, a route got much slower after go-live, so the on-time rate barely moved even though the simulated worklist helped. The holdout must be agreed with the process owner, and can be stopped once the effect is proven.

Architecture at a glance

flowchart LR
  S[SAP S/4HANA<br/>Outbound Delivery API] -->|read only| D[Delivery copy]
  D --> T[Training<br/>monthly]
  T --> C[Candidate model<br/>with model card]
  C -->|second person approves| L[Live model]
  L --> A[Worklist API]
  P[Planner in browser] --> A
  A --> G[(Decision and<br/>prediction log)]
  A --> X[Expedite list]
  X -->|planner acts in SAP| S
  G --> M[Weekly monitoring]
  M -->|retrain| T
  G --> K[KPI report]

Where SAP fits. As of October 2026, SAP offers several routes, and some customers will need no custom build at all:

  • Embedded in S/4HANA. SAP publishes an asset on SAP S/4HANA Predictive Delivery Delays. A partner blog from NTT DATA describes the Predicted Delivery Delay app, which predicts delay in days per sales order item. SAP's embedded machine learning scenarios are managed through Intelligent Scenario Lifecycle Management (ISLM), which, according to SAP's overview, includes analyzing model quality and an explicit activation step. Check your release's documentation for how this particular scenario is trained.
  • SAP AI Core for your own model. SAP's Architecture Center recommends it for custom models and MLOps-heavy work, and lists retraining pipelines, model versions and A/B testing.
  • SAP-RPT-1, a pretrained tabular model, which SAP's Architecture Center names as the default for classification and regression. Tabular AI and SAP's predictive models covers it.

So the first question is "does the embedded scenario predict what our planners need, at the moment they need it?" This project is worth building when the answer is no: your own definition of late, your own data such as carrier events, or a worklist the embedded app doesn't give.

Risks and controls

Risk What can go wrong Control in this design
Leakage A field filled after shipping makes the model look perfect and useless The API refuses any field except the creation-time features; a test proves it
Drift Routes, carriers and volumes change; the model keeps old habits Weekly monitoring with alarms; monthly retraining; a backtest shows what retraining is worth
Wrong flags Too many flags, and planners stop looking A cost-based threshold, a flag-volume guardrail, and reasons next to every flag
Authorizations A planner sees or decides for deliveries outside their area The API filters by shipping point and checks it again on every decision
Unapproved changes A tool reroutes deliveries, or a model goes live unchecked The system can't write to SAP; a second person promotes every model
Unsafe model files A tampered model file runs code when loaded Only files whose fingerprint matches their model card are loaded
API terms Copying delivery history breaks SAP's API policy Confirm the extraction route with SAP before go-live

What to show a stakeholder

A three-minute demo script:

  1. The pain (30 s). Show last quarter's numbers: on-time rate and express freight spent after deliveries were already late.
  2. The worklist (45 s). Open it as Omar. Point at the top flag and its reasons, and the overdue line. Click Expedite and type what you would do in SAP. Switch to Pia: she sees different deliveries and can't decide for Omar's.
  3. The honesty (45 s). Show the backtest: a model frozen in June against one retrained monthly, on July to September. Show the monitoring alarm for the June model: a slower route and a route it has never seen.
  4. The control (30 s). Show that the person who trained a model can't make it live, and that a rollback is one command.
  5. The value (30 s). Show the KPI report and say clearly that the sample numbers are simulated. Explain the holdout and when you will have real numbers.

Questions to ask

  1. Which dates define "late" for us, and when must the prediction be made: at order, at delivery creation, or the day before goods issue?
  2. Does the embedded Predicted Delivery Delay scenario already cover this? Who has looked at its target and inputs?
  3. What does a late delivery cost us, and what does an early fix cost? Who signs those numbers?
  4. How many flags per week can each planner handle?
  5. Will the process owner agree to a holdout, and for how long?
  6. Who owns the model: who retrains it, who approves it, who watches the weekly report?
  7. How do planners' shipping point authorizations in SAP map to the worklist?
  8. How do we extract delivery history within SAP's API policy, and who confirmed it with SAP?

Check yourself

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

1Why does this project measure its KPI against a holdout instead of comparing before and after go-live?

Answer: A. Other changes happen at the same time as go-live. In the project's simulation, a route got slower and the on-time rate barely moved, while the holdout comparison still showed the effect.

2When does expediting flagged deliveries pay off?

Answer: D. Expediting costs money on every flag, including false alarms. It only pays if the spend per late delivery actually avoided stays below what a late delivery costs, 250 EUR in the sample.

3A planner clicks Expedite in the worklist. What happens in SAP?

Answer: C. The system records the decision and writes an expedite list, but it has no way to write to SAP. The planner carries out the change with their own authorizations, so SAP's checks and audit trail still apply.

4Your S/4HANA system offers the Predicted Delivery Delay app. What should you check before building this project?

Answer: B. The partner description says it predicts delay per sales order item. If that target and moment fit your process, a custom build may not be needed. Build when your definition of late, data or worklist differs.

5Weekly monitoring says a route got much slower and a new route has appeared. What is the right response?

Answer: A. The model only knows its training period. Retraining teaches it the new pattern, the evaluation shows whether the candidate is better, and the approval keeps an unchecked model from going live.

6Why must a second person approve every new model before it goes live?

Answer: B. A model changes what planners see every morning. A four-eyes rule, enforced in code, makes someone other than the builder check the model card and test results, and the promotion log records who approved what and when.

7The vendor's demo shows a perfect test score for their delay model. What do you ask first?

Answer: B. Perfect scores on business data usually mean leakage: a field that holds the answer after the fact. Such a model looks perfect in testing and flags nothing useful on open deliveries.
Deep layer · 60 min read

What you will build

You will turn the late-delivery model from Predicting late deliveries with SAP data into a small, complete product: a worklist that planners open every morning, an API other systems can call, a model that only goes live with a second person's approval, weekly monitoring with a retraining plan, and a KPI measured against a holdout.

flowchart LR
  S["Stage 1: SAP data<br/>sap_data.py"] --> M["Stage 2: model<br/>late_model.py"]
  M --> A["Stage 3: API<br/>app.py"]
  A --> U["Stage 4: worklist page<br/>served by app.py"]
  A --> D["Stage 5: deploy<br/>manifest.yml"]
  M --> E["Stage 6: backtest and monitoring<br/>evaluate.py, monitor.py"]
  E -->|retrain| M
  A -->|events.jsonl| K["Stage 7: KPI<br/>kpi.py"]
File Stage What it does
sap_data.py 1 Gets outbound deliveries as data/deliveries.json
late_model.py 2 Trains a candidate model, writes its model card, promotes it with a second person's approval
app.py 3, 4 The API and the worklist page; logs predictions and decisions
test_app.py 3 Contract tests for the business rules
requirements.txt, runtime.txt, manifest.yml, .cfignore 5 Deployment to Cloud Foundry
evaluate.py 6 Backtest: a frozen model against monthly retraining
monitor.py 6 Weekly checks and a retrain recommendation
kpi.py 7 On-time rate and expedite spend, against the holdout

Everything runs on made-up, SAP-shaped data without any account. SAP's sandbox is an optional switch.

Prerequisites. You should have done these published topics, or be ready to look back at them:

Time: 4 to 6 hours, over a few sessions. Cost: free. The sandbox key and the SAP BTP trial are free.

Stage 1: SAP data

Before you start: complete Set up your computer for this course, Set up for Unit 2 and Set up for Unit 6. They give you the orchestrate-course folder, its .venv, pandas, scikit-learn, FastAPI, requests, python-dotenv and your SAP_API_KEY in .env. This project adds no new libraries.

In this stage you get outbound deliveries into one file, data/deliveries.json, in the exact JSON shape of SAP's Outbound Delivery API. Every later stage reads only that file and data/source.json, which records the date the data describes.

flowchart LR
  A["--sample<br/>made-up deliveries"] --> F["data/deliveries.json<br/>data/source.json"]
  B["--sandbox<br/>API_OUTBOUND_DELIVERY_SRV"] --> F

What the SAP data looks like

The data is the same as in Unit 2: the entity A_OutbDeliveryHeader of the OData V2 service API_OUTBOUND_DELIVERY_SRV, with the items under the navigation property to_DeliveryDocumentItem. A connector generated from the API definition lists all the header fields used here.

Field Used as
CreationDate Time order; start of the planned lead time
PlannedGoodsIssueDate Label (planned side); lead time; weekday
ActualGoodsMovementDate Label (actual side) and "is the outcome known yet?". Never a feature
ShippingPoint Feature, and the planners' area of responsibility
ShippingType, ShippingCondition, DeliveryPriority, ProposedDeliveryRoute Features
HeaderGrossWeight, HeaderWeightUnit Feature, with a unit check
Item ActualDeliveryQuantity Feature: item count and total quantity

Shipping point and route matter for a reason: SAP Learning's lesson on delivery scheduling explains that loading and pick/pack times come from the shipping point, and transit and transportation lead times from the route. A route that is suddenly late often means its planned durations no longer match reality.

The made-up data covers October 2025 to 9 October 2026, the data's "today". Two changes are built in on purpose, so Stage 6 has something to find: from 15 July 2026 route R17102 gets much slower, and from 1 September 2026 a new route R17103 appears at shipping point 1710.

What you need

  • About 20 minutes. No cost, no account, no internet for --sample.
  • Optional: your free SAP_API_KEY for --sandbox.

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

  1. Open VS Code, choose File > Open Folder and open orchestrate-course.

  2. Open a terminal: Terminal > New Terminal.

  3. Turn on the virtual environment if the prompt doesn't start with (.venv):

    • Windows (PowerShell):

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

      source .venv/bin/activate

All commands in this project run from the orchestrate-course folder. Don't cd into the project folder unless a step says so.

Step 2: Create the project folder

  1. Create the folder:

    • Windows (PowerShell):

      mkdir projects\late-delivery
    • macOS / Linux:

      mkdir -p projects/late-delivery
  2. Keep generated files out of Git. Open .gitignore in the course folder, add these lines at the end and save:

    projects/late-delivery/data/
    projects/late-delivery/logs/
    projects/late-delivery/exports/
    projects/late-delivery/models/

    The data and models are regenerated by the scripts, the logs hold planners' decisions, and the exports are work orders. None belong in a public portfolio. The reports/ folder stays: it is portfolio material.

Step 3: Save and run the data script

  1. In VS Code's file list, right-click projects/late-delivery, choose New File, name it sap_data.py, paste the code below and save (Ctrl+S, or Cmd+S on Mac).
"""Stage 1: get outbound deliveries into data/deliveries.json, in the JSON shape of API_OUTBOUND_DELIVERY_SRV.

How to run (from your orchestrate-course folder, with .venv turned on):
    python projects/late-delivery/sap_data.py --sample     # made-up deliveries, no key, no internet
    python projects/late-delivery/sap_data.py --sandbox    # SAP Business Accelerator Hub sandbox (SAP_API_KEY in .env)

Every later stage reads only data/deliveries.json and data/source.json, so it does not matter where they came from.
The script only reads (GET) from SAP. It never changes data.
"""
import argparse
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path

import numpy as np
import pandas as pd

HERE = Path(__file__).parent
DATA = HERE / "data"
SERVICE = "/sap/opu/odata/sap/API_OUTBOUND_DELIVERY_SRV"
SANDBOX = "https://sandbox.api.sap.com/s4hanacloud" + SERVICE
SAMPLE_AS_OF = pd.Timestamp("2026-10-09")   # the made-up data's "today"


def odata_date(ts: pd.Timestamp) -> str:
    """Write a date the way OData V2 JSON does: /Date(milliseconds since 1 Jan 1970)/."""
    return f"/Date({int(ts.timestamp() * 1000)})/"


def make_sample(rows: int = 3300, seed: int = 11) -> dict:
    """Made-up outbound deliveries shaped like A_OutbDeliveryHeader with to_DeliveryDocumentItem.

    Two changes are built in, so monitoring has something to find:
    from 15 July 2026 route R17102 gets slower, and from 1 September 2026 a new route R17103 appears.
    """
    rng = np.random.default_rng(seed)
    start = pd.Timestamp("2025-10-01")
    created = np.sort(start + pd.to_timedelta(rng.integers(0, 374, size=rows), unit="D"))
    routes = {"1010": ["R10101", "R10102"], "1020": ["R10201", "R10202"], "1710": ["R17101", "R17102"]}
    results = []
    for i in range(rows):
        creation = pd.Timestamp(created[i])
        point = str(rng.choice(["1010", "1020", "1710"], p=[0.45, 0.35, 0.20]))
        route = str(rng.choice(routes[point]))
        if point == "1710" and creation >= pd.Timestamp("2026-09-01") and rng.random() < 0.5:
            route = "R17103"                                   # a new carrier route, unknown to older models
        shipping_type = str(rng.choice(["01", "02", "03"], p=[0.6, 0.3, 0.1]))
        condition = str(rng.choice(["01", "02"], p=[0.8, 0.2]))
        priority = str(rng.choice(["02", "01"], p=[0.85, 0.15]))
        lead = int(rng.integers(1, 11))
        planned_gi = creation + pd.Timedelta(days=lead)
        n_items = int(rng.poisson(2.5)) + 1
        items, weight = [], 0.0
        for j in range(n_items):
            qty = int(rng.integers(1, 40))
            item_weight = round(qty * float(rng.gamma(2.0, 4.0)), 3)
            weight += item_weight
            items.append({"DeliveryDocument": str(80000000 + i), "DeliveryDocumentItem": f"{(j + 1) * 10:06d}",
                          "Material": f"TG{int(rng.integers(10, 30))}", "Plant": point,
                          "ActualDeliveryQuantity": f"{qty:.3f}", "ItemGrossWeight": f"{item_weight:.3f}",
                          "ItemWeightUnit": "KG"})
        risk = (-2.4 + 0.22 * n_items + 0.0012 * weight - 0.20 * lead
                + (0.9 if point == "1710" else 0.0)
                + (-0.8 if condition == "02" else 0.0)
                + (-0.5 if priority == "01" else 0.0)
                + (0.6 if planned_gi.dayofweek == 4 else 0.0)
                + (2.5 if route == "R17102" and creation >= pd.Timestamp("2026-07-15") else 0.0)
                + (1.5 if route == "R17103" else 0.0)
                + rng.logistic(0, 0.8))
        delay = int(rng.geometric(0.45)) if risk > 0 else -int(rng.integers(0, 2))
        actual_gi = planned_gi + pd.Timedelta(days=delay)
        is_open = actual_gi > SAMPLE_AS_OF                  # goods issue not posted yet on "today"
        results.append({
            "DeliveryDocument": str(80000000 + i),
            "ShippingPoint": point, "ShippingType": shipping_type, "ShippingCondition": condition,
            "DeliveryPriority": priority, "ProposedDeliveryRoute": route,
            "ShipToParty": f"10{int(rng.integers(100, 160)):05d}",
            "CreationDate": odata_date(creation),
            "PlannedGoodsIssueDate": odata_date(planned_gi),
            "ActualGoodsMovementDate": None if is_open else odata_date(actual_gi),
            "HeaderGrossWeight": f"{weight:.3f}", "HeaderWeightUnit": "KG",
            "to_DeliveryDocumentItem": {"results": items},
        })
    return {"d": {"results": results}}


def fetch_sandbox(key: str, top: int, max_pages: int) -> dict:
    """Read outbound deliveries with items from the SAP Business Accelerator Hub sandbox (GET only)."""
    try:
        import requests
    except ModuleNotFoundError:
        sys.exit("The requests library is missing. Run: pip install -r requirements.txt (or use --sample).")
    session = requests.Session()
    session.headers.update({"APIKey": key, "Accept": "application/json"})
    url = f"{SANDBOX}/A_OutbDeliveryHeader"
    params = {"$top": top, "$expand": "to_DeliveryDocumentItem", "$format": "json"}
    results = []
    for _ in range(max_pages):
        try:
            response = session.get(url, params=params, timeout=30)
        except requests.exceptions.RequestException as err:
            sys.exit(f"Could not reach sandbox.api.sap.com ({type(err).__name__}). "
                     "Check your network or proxy, or run with --sample.")
        if response.status_code != 200:
            hint = {401: "the key is missing or wrong: check SAP_API_KEY in .env",
                    403: "the key is not allowed to call this API",
                    404: "the service path is wrong or the API is not on the sandbox"}
            sys.exit(f"Stopped: HTTP {response.status_code}, {hint.get(response.status_code, 'see the message above')}.")
        body = response.json()["d"]
        results.extend(body.get("results", []))
        if "__next" not in body:
            break
        url, params = body["__next"], None
    return {"d": {"results": results}}


def parse_date(value):
    """'/Date(1759190400000)/' -> Timestamp. Empty values become NaT (not a time)."""
    if not value:
        return pd.NaT
    match = re.search(r"/Date\((-?\d+)", str(value))
    return pd.to_datetime(int(match.group(1)), unit="ms") if match else pd.NaT


def flatten(payload: dict) -> pd.DataFrame:
    """One row per delivery: header fields plus item count and total quantity."""
    rows = []
    for h in payload["d"]["results"]:
        items = (h.get("to_DeliveryDocumentItem") or {}).get("results", [])
        rows.append({
            "DeliveryDocument": h.get("DeliveryDocument"),
            "ShippingPoint": h.get("ShippingPoint") or "(blank)",
            "ShippingType": h.get("ShippingType") or "(blank)",
            "ShippingCondition": h.get("ShippingCondition") or "(blank)",
            "DeliveryPriority": h.get("DeliveryPriority") or "(blank)",
            "ProposedDeliveryRoute": h.get("ProposedDeliveryRoute") or "(blank)",
            "CreationDate": parse_date(h.get("CreationDate")),
            "PlannedGoodsIssueDate": parse_date(h.get("PlannedGoodsIssueDate")),
            "ActualGoodsMovementDate": parse_date(h.get("ActualGoodsMovementDate")),
            "GrossWeight": float(h.get("HeaderGrossWeight") or 0),
            "WeightUnit": h.get("HeaderWeightUnit") or "",
            "Items": len(items),
            "TotalQuantity": sum(float(i.get("ActualDeliveryQuantity") or 0) for i in items),
        })
    df = pd.DataFrame(rows)
    if df.empty:
        return df
    return df.dropna(subset=["CreationDate", "PlannedGoodsIssueDate"]).sort_values("CreationDate").reset_index(drop=True)


def load_deliveries(data_dir: Path = DATA):
    """What every later stage calls: the delivery table and the date the data describes ("as of")."""
    payload_file, source_file = data_dir / "deliveries.json", data_dir / "source.json"
    if not payload_file.exists():
        sys.exit("No data/deliveries.json yet. Run: python projects/late-delivery/sap_data.py --sample")
    source = json.loads(source_file.read_text(encoding="utf-8"))
    table = flatten(json.loads(payload_file.read_text(encoding="utf-8")))
    return table, pd.Timestamp(source["as_of"]), source


def main() -> None:
    parser = argparse.ArgumentParser(description="Stage 1: get outbound deliveries.")
    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument("--sample", action="store_true", help="made-up deliveries; no key or internet")
    group.add_argument("--sandbox", action="store_true", help="read the SAP sandbox (needs SAP_API_KEY)")
    parser.add_argument("--top", type=int, default=200, help="deliveries per sandbox page")
    parser.add_argument("--max-pages", type=int, default=5, help="safety limit on sandbox pages")
    args = parser.parse_args()

    if args.sample:
        payload, source, as_of = make_sample(), "sample", SAMPLE_AS_OF
    else:
        try:
            from dotenv import load_dotenv
            load_dotenv()
        except ModuleNotFoundError:
            pass
        key = os.environ.get("SAP_API_KEY")
        if not key:
            sys.exit("SAP_API_KEY is not set. Add it to .env, or run with --sample.")
        payload, source = fetch_sandbox(key, args.top, args.max_pages), "sandbox"
        as_of = pd.Timestamp(datetime.now(timezone.utc).date())

    DATA.mkdir(exist_ok=True)
    (DATA / "deliveries.json").write_text(json.dumps(payload, indent=1), encoding="utf-8")
    (DATA / "source.json").write_text(json.dumps({
        "source": source, "as_of": str(as_of.date()),
        "read_at": datetime.now(timezone.utc).isoformat(timespec="seconds")}, indent=1), encoding="utf-8")

    table = flatten(payload)
    if table.empty:
        print(f"Source: {source}. The answer held no deliveries: a valid, empty result.")
        return
    known = table["ActualGoodsMovementDate"].notna()
    late = (table["ActualGoodsMovementDate"] > table["PlannedGoodsIssueDate"])[known]
    print(f"Source: {source}. As of {as_of.date()}: {len(table)} deliveries, "
          f"created {table['CreationDate'].min().date()} to {table['CreationDate'].max().date()}.")
    print(f"  Goods issue posted (outcome known): {known.sum()}, late: {late.mean():.0%}. Still open: {(~known).sum()}.")
    print(f"  Shipping points: {', '.join(sorted(table['ShippingPoint'].unique()))}. "
          f"Routes: {table['ProposedDeliveryRoute'].nunique()}.")
    if table["WeightUnit"].nunique() > 1:
        print(f"  Warning: mixed weight units {sorted(table['WeightUnit'].unique())}; convert before training.")
    print("Wrote data/deliveries.json and data/source.json")


if __name__ == "__main__":
    main()
  1. Run it with made-up data:

    python projects/late-delivery/sap_data.py --sample

What success looks like:

Source: sample. As of 2026-10-09: 3300 deliveries, created 2025-10-01 to 2026-10-09.
  Goods issue posted (outcome known): 3254, late: 15%. Still open: 46.
  Shipping points: 1010, 1020, 1710. Routes: 7.
Wrote data/deliveries.json and data/source.json

3,254 deliveries have a posted goods issue, so their outcome is known; 15% of them left late. 46 are still open: those become the worklist in Stage 3. Open data/deliveries.json and find PlannedGoodsIssueDate and ActualGoodsMovementDate in the first record: the label is those two dates compared.

Step 4 (optional): Read the sandbox instead

  1. Check that .env in the course folder has the line SAP_API_KEY="...". If not, follow the key steps in the setup topic.

  2. Run:

    python projects/late-delivery/sap_data.py --sandbox
  3. What success looks like: Source: sandbox., the number of deliveries, how many have a goods issue, and Wrote data/deliveries.json. The sandbox holds demo data, so expect far too few deliveries to train a model. Stage 2 will then stop with Only N deliveries with a known outcome. That message is a valid result: the extraction worked. Run --sample again to continue the project.

What the code does

Part What it does
make_sample Builds made-up deliveries in the OData V2 JSON shape, with the slower route from mid-July and a new route from September
fetch_sandbox Reads A_OutbDeliveryHeader with $expand=to_DeliveryDocumentItem, sends the key as the APIKey header, follows __next links up to --max-pages
parse_date, flatten Turn /Date(ms)/ strings into dates and nested records into one row per delivery
load_deliveries What every later stage calls: the table plus the "as of" date from data/source.json
main Saves both files and prints a short profile: outcomes known, late share, open deliveries

If something goes wrong

What you see What it means What to do
python is not recognized, or command not found Python isn't installed, or the terminal can't find it Windows: repeat the Python step of the Unit 1 setup, then open a new terminal. macOS/Linux: use python3 until .venv is active
ModuleNotFoundError: No module named 'pandas' The library isn't in the Python you're using Check for (.venv) in the prompt, then run pip install -r requirements.txt from the course folder
can't open file ... sap_data.py You are not in the course folder, or the file has another name Run pwd (macOS/Linux) or Get-Location (PowerShell); it must end in orchestrate-course
SAP_API_KEY is not set The script can't find your key Check .env is in the course folder, or use --sample
Stopped: HTTP 401 The key is wrong or incomplete Copy it again with Show API Key on the SAP Business Accelerator Hub
Could not reach sandbox.api.sap.com Your network or a company proxy blocks it Try another network, or ask IT to allow sandbox.api.sap.com. --sample still works

Stage 2: Model

Before you start: complete Set up your computer for this course, Set up for Unit 2 and Set up for Unit 6, and Stage 1 above.

Unit 2's script trained a model and printed numbers. A model that runs in a product needs three more things:

  1. It is saved with a model card. scikit-learn's guide on model persistence says what to keep next to a saved model: a reference to the training data, the code, the library versions and the cross-validation score. Without them, nobody can rebuild or audit it.
  2. It is loaded safely. The same guide warns that pickle and joblib files can run arbitrary code when loaded, so you must never load one from an untrusted source. It also says loading a model saved with a different scikit-learn version is unsupported. The script refuses both cases: it checks the file's fingerprint against the card, and the version.
  3. It goes live by a decision, not by a file copy. Training only creates a candidate. A second person promotes it, and the script records who and when.
flowchart LR
  D[data/deliveries.json] --> T[train: test split,<br/>model choice, threshold]
  T --> F[refit on all<br/>known outcomes]
  F --> C["models/m20261009.joblib<br/>+ model card .json"]
  C -->|promote, second person| L[models/active.json]
  L --> API[app.py loads it]

The training follows Unit 2 step by step: label from the two dates, features known at creation, a time split for the test, two models compared on TimeSeriesSplit folds, and a threshold from the costs with TunedThresholdClassifierCV. One thing is new. After testing, the model is refit on every known outcome, including the test period, because the newest weeks are the most valuable for the live model. The test numbers in the card describe how this recipe performed; Stage 6 checks how the live model performs.

The card also stores a reference: the late share per route over the last 90 days of training data, and every code the model has seen. Stage 6 compares new weeks against it.

What you need

  • About 30 minutes. No cost.

Step 1: Save the model script

  1. In projects/late-delivery, create late_model.py, paste the code below and save.
"""Stage 2: train, save and promote the late-delivery model. The API and the monitoring use the functions here.

How to run (from your orchestrate-course folder):
    python projects/late-delivery/late_model.py train --by rt                 # candidate from all outcomes known today
    python projects/late-delivery/late_model.py train --as-of 2026-06-30 --by rt   # as if it were 30 June
    python projects/late-delivery/late_model.py promote m20261009 --approved-by mia   # a second person makes it live
    python projects/late-delivery/late_model.py list
"""
import argparse
import hashlib
import json
import sys
import warnings
from datetime import datetime, timezone
from pathlib import Path

import joblib
import numpy as np
import pandas as pd
import sklearn
from sklearn.base import clone
from sklearn.compose import ColumnTransformer
from sklearn.exceptions import InconsistentVersionWarning
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score, confusion_matrix, make_scorer, roc_auc_score
from sklearn.model_selection import TimeSeriesSplit, TunedThresholdClassifierCV, cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

from sap_data import load_deliveries

HERE = Path(__file__).parent
MODELS = HERE / "models"
CATEGORICAL = ["ShippingPoint", "ShippingType", "ShippingCondition", "DeliveryPriority",
               "ProposedDeliveryRoute", "PlannedGIWeekday"]
NUMERIC = ["Items", "TotalQuantity", "GrossWeight", "PlannedLeadDays"]
FEATURES = CATEGORICAL + NUMERIC
MIN_KNOWN = 300
LABELS = {"ShippingPoint": "shipping point", "ShippingType": "shipping type", "ShippingCondition": "shipping condition",
          "DeliveryPriority": "priority", "ProposedDeliveryRoute": "route", "PlannedGIWeekday": "goods issue on",
          "Items": "items", "TotalQuantity": "quantity", "GrossWeight": "weight", "PlannedLeadDays": "lead time"}


# ---------------------------------------------------------------- label, features, what is known when

def add_features(df: pd.DataFrame) -> pd.DataFrame:
    """Late = goods issue posted after the planned date. Features = what a delivery shows when it is created."""
    df = df.copy()
    delay = (df["ActualGoodsMovementDate"] - df["PlannedGoodsIssueDate"]).dt.days
    df["Late"] = np.where(delay.isna(), np.nan, (delay > 0).astype(float))
    df["PlannedLeadDays"] = (df["PlannedGoodsIssueDate"] - df["CreationDate"]).dt.days
    df["PlannedGIWeekday"] = df["PlannedGoodsIssueDate"].dt.day_name().str[:3]
    return df


def known_as_of(df: pd.DataFrame, as_of: pd.Timestamp) -> pd.DataFrame:
    """Deliveries whose outcome existed on the as-of date: goods issue posted on or before it."""
    out = df[df["ActualGoodsMovementDate"] <= as_of].copy()
    out["Late"] = out["Late"].astype(int)
    return out


def open_as_of(df: pd.DataFrame, as_of: pd.Timestamp) -> pd.DataFrame:
    """Deliveries created by the as-of date whose goods issue had not been posted yet: the worklist."""
    gi = df["ActualGoodsMovementDate"]
    return df[(df["CreationDate"] <= as_of) & (gi.isna() | (gi > as_of))].copy()


# ---------------------------------------------------------------- training

def preprocessor() -> ColumnTransformer:
    return ColumnTransformer([
        ("categories", OneHotEncoder(handle_unknown="ignore", sparse_output=False), CATEGORICAL),
        ("numbers", StandardScaler(), NUMERIC)])


def cost_of(y_true, flagged, cost_miss: float, cost_check: float) -> float:
    _, fp, fn, tp = confusion_matrix(y_true, flagged, labels=[0, 1]).ravel()
    return float((tp + fp) * cost_check + fn * cost_miss)


def neg_cost(y_true, flagged, cost_miss, cost_check):
    return -cost_of(y_true, flagged, cost_miss, cost_check) / len(y_true)


def judge(model, rows: pd.DataFrame, cost_miss: float, cost_check: float) -> dict:
    """The numbers the business reads, on rows the model never trained on."""
    y = rows["Late"].to_numpy()
    scores = model.predict_proba(rows[FEATURES])[:, 1]
    flagged = model.predict(rows[FEATURES])
    _, fp, fn, tp = confusion_matrix(y, flagged, labels=[0, 1]).ravel()
    weeks = max(1.0, (rows["CreationDate"].max() - rows["CreationDate"].min()).days / 7)
    two_classes = len(set(y)) == 2
    return {"rows": int(len(rows)), "late": int(y.sum()),
            "auc": round(float(roc_auc_score(y, scores)), 3) if two_classes else None,
            "avg_precision": round(float(average_precision_score(y, scores)), 3) if two_classes else None,
            "flagged": int(tp + fp), "caught": int(tp),
            "recall": round(tp / max(tp + fn, 1), 3), "precision": round(tp / max(tp + fp, 1), 3),
            "flags_per_week": round((tp + fp) / weeks, 1),
            "cost": cost_of(y, flagged, cost_miss, cost_check),
            "cost_flag_nothing": float(y.sum() * cost_miss)}


def train_model(df: pd.DataFrame, as_of: pd.Timestamp, cost_miss=250.0, cost_check=50.0, quiet=False):
    """Compare two models on time-ordered folds, test once on the newest period, then refit on everything known."""
    say = (lambda *a: None) if quiet else print
    known = known_as_of(df, as_of).sort_values("CreationDate")
    if len(known) < MIN_KNOWN or known["Late"].nunique() < 2:
        sys.exit(f"Only {len(known)} deliveries with a known outcome on {as_of.date()}: too few to train "
                 f"(this project wants {MIN_KNOWN}). Use --sample data or a larger extract.")
    cutoff = known["CreationDate"].quantile(0.75).normalize()
    train = known[known["ActualGoodsMovementDate"] < cutoff]
    test = known[known["CreationDate"] >= cutoff]
    say(f"As of {as_of.date()}: {len(known)} deliveries with a known outcome. "
        f"Test split at {cutoff.date()}: train {len(train)}, test {len(test)}.")

    folds = TimeSeriesSplit(n_splits=5)
    candidates = {
        "logistic_regression": make_pipeline(preprocessor(), LogisticRegression(max_iter=1000)),
        "gradient_boosting": make_pipeline(preprocessor(), HistGradientBoostingClassifier(max_depth=3, random_state=0)),
    }
    cv = {name: float(cross_val_score(m, train[FEATURES], train["Late"], cv=folds,
                                      scoring="average_precision").mean()) for name, m in candidates.items()}
    best = max(cv, key=cv.get)
    say("  Average precision on 5 time-ordered folds: "
        + ", ".join(f"{n} {s:.2f}" for n, s in cv.items()) + f". Chosen: {best}")

    scorer = make_scorer(neg_cost, cost_miss=cost_miss, cost_check=cost_check)
    tested = TunedThresholdClassifierCV(clone(candidates[best]), scoring=scorer, cv=folds)
    tested.fit(train[FEATURES], train["Late"])
    test_result = judge(tested, test, cost_miss, cost_check)
    say(f"  Test ({test_result['rows']} deliveries, {test_result['late']} late): AUC {test_result['auc']}, "
        f"recall {test_result['recall']}, precision {test_result['precision']}, "
        f"{test_result['flags_per_week']} flags/week, cost {test_result['cost']:,.0f} EUR "
        f"vs {test_result['cost_flag_nothing']:,.0f} EUR flagging nothing")

    # The model that goes live learns from every known outcome, including the test period.
    final = TunedThresholdClassifierCV(clone(candidates[best]), scoring=scorer, cv=folds)
    final.fit(known[FEATURES], known["Late"])
    explainer = make_pipeline(preprocessor(), LogisticRegression(max_iter=1000)).fit(known[FEATURES], known["Late"])
    say(f"  Final model refit on all {len(known)} known deliveries. Threshold {final.best_threshold_:.2f}")

    recent = known[known["CreationDate"] > as_of - pd.Timedelta(days=90)]
    card = {
        "version": f"m{as_of:%Y%m%d}", "as_of": str(as_of.date()), "model_type": best,
        "threshold": round(float(final.best_threshold_), 3),
        "costs": {"miss": cost_miss, "check": cost_check},
        "rows": {"known": int(len(known)), "train": int(len(train)), "test": int(len(test))},
        "cv_avg_precision": {n: round(s, 3) for n, s in cv.items()},
        "test": test_result,
        "reference": {   # what "normal" looked like in the last 90 days of training data; monitoring compares to it
            "from": str((as_of - pd.Timedelta(days=90)).date()),
            "late_share": round(float(recent["Late"].mean()), 3),
            "late_share_by_route": {r: round(float(g["Late"].mean()), 3)
                                    for r, g in recent.groupby("ProposedDeliveryRoute") if len(g) >= 20},
            "categories": {c: sorted(known[c].astype(str).unique()) for c in CATEGORICAL},
        },
        "features": FEATURES,
        "sklearn_version": sklearn.__version__,
    }
    return {"model": final, "explainer": explainer}, card


def save(bundle: dict, card: dict, source: dict, trained_by: str) -> Path:
    MODELS.mkdir(exist_ok=True)
    path = MODELS / f"{card['version']}.joblib"
    joblib.dump(bundle, path)
    card = {**card, "trained_by": trained_by, "trained_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
            "data_source": source["source"], "sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
    (MODELS / f"{card['version']}.json").write_text(json.dumps(card, indent=1), encoding="utf-8")
    return path


# ---------------------------------------------------------------- loading, scoring, explaining

def load_card(version: str) -> dict:
    path = MODELS / f"{version}.json"
    if not path.exists():
        sys.exit(f"No model card {path.name}. Run: python projects/late-delivery/late_model.py list")
    return json.loads(path.read_text(encoding="utf-8"))


def active_version() -> str:
    path = MODELS / "active.json"
    if not path.exists():
        sys.exit("No model is live yet. Train one, then promote it with a second person's name (Stage 2).")
    return json.loads(path.read_text(encoding="utf-8"))["version"]


def load_model(version: str | None = None):
    """Load only a file whose fingerprint matches its card, trained with this scikit-learn version."""
    card = load_card(version or active_version())
    path = MODELS / f"{card['version']}.joblib"
    if hashlib.sha256(path.read_bytes()).hexdigest() != card["sha256"]:
        sys.exit(f"{path.name} does not match the fingerprint in its card. Refusing to load it.")
    if card["sklearn_version"] != sklearn.__version__:
        sys.exit(f"{path.name} was trained with scikit-learn {card['sklearn_version']}, this is {sklearn.__version__}. "
                 "Retrain it here, or install the same version.")
    with warnings.catch_warnings():
        warnings.simplefilter("error", InconsistentVersionWarning)   # a version mismatch stops the load
        bundle = joblib.load(path)
    return bundle, card


def score(bundle: dict, card: dict, rows: pd.DataFrame) -> pd.DataFrame:
    """Score, flag and give the two strongest reasons per delivery."""
    out = rows[["DeliveryDocument"]].copy()
    out["score"] = bundle["model"].predict_proba(rows[FEATURES])[:, 1].round(3)
    out["flag"] = out["score"] >= card["threshold"]
    pre, lr = bundle["explainer"][0], bundle["explainer"][-1]
    contributions = pre.transform(rows[FEATURES]) * lr.coef_[0]
    names = [n.split("__", 1)[1] for n in pre.get_feature_names_out()]
    reasons = []
    for row in contributions:
        top = [i for i in np.argsort(row)[::-1][:2] if row[i] > 0.05]
        reasons.append([readable(names[i]) for i in top])
    out["reasons"] = reasons
    return out


def readable(name: str) -> str:
    if name in NUMERIC:
        return {"PlannedLeadDays": "short lead time"}.get(name, f"high {LABELS[name]}")
    column, value = next((c, name[len(c) + 1:]) for c in CATEGORICAL if name.startswith(c + "_"))
    return f"{LABELS[column]} {value}"


def md_table(df: pd.DataFrame) -> str:
    """A DataFrame as a Markdown table, without extra libraries."""
    lines = ["| " + " | ".join(map(str, df.columns)) + " |", "|" + " --- |" * len(df.columns)]
    lines += ["| " + " | ".join(map(str, row)) + " |" for row in df.itertuples(index=False)]
    return "\n".join(lines)


# ---------------------------------------------------------------- promotion: a second person makes a model live

def promote(version: str, approved_by: str) -> None:
    card = load_card(version)
    load_model(version)                                   # fingerprint and version checks
    if approved_by.strip().lower() == card["trained_by"].strip().lower():
        sys.exit(f"Refused: {approved_by} trained {version}. A second person must approve it.")
    test = card["test"]
    if test["cost"] >= test["cost_flag_nothing"]:
        sys.exit(f"Refused: on its test period {version} costs {test['cost']:,.0f} EUR, "
                 f"not less than flagging nothing ({test['cost_flag_nothing']:,.0f} EUR).")
    active = MODELS / "active.json"
    previous = json.loads(active.read_text(encoding="utf-8"))["version"] if active.exists() else None
    record = {"version": version, "previous": previous, "approved_by": approved_by,
              "approved_at": datetime.now(timezone.utc).isoformat(timespec="seconds")}
    active.write_text(json.dumps(record, indent=1), encoding="utf-8")
    with open(MODELS / "promotions.jsonl", "a", encoding="utf-8") as f:
        f.write(json.dumps(record) + "\n")
    print(f"{version} is live, approved by {approved_by}. Previous: {previous or 'none'}.")


def main() -> None:
    parser = argparse.ArgumentParser(description="Stage 2: train, promote and list late-delivery models.")
    sub = parser.add_subparsers(dest="command", required=True)
    t = sub.add_parser("train", help="train a candidate model")
    t.add_argument("--as-of", help="train as if today were this date (YYYY-MM-DD); default: the data's date")
    t.add_argument("--by", required=True, help="your name, recorded in the model card")
    t.add_argument("--cost-miss", type=float, default=250.0, help="EUR lost when a late delivery is not flagged")
    t.add_argument("--cost-check", type=float, default=50.0, help="EUR to check and act on one flag")
    p = sub.add_parser("promote", help="make a trained model live")
    p.add_argument("version")
    p.add_argument("--approved-by", required=True, help="the approver; must not be the person who trained it")
    sub.add_parser("list", help="show trained models")
    args = parser.parse_args()

    if args.command == "train":
        table, data_as_of, source = load_deliveries()
        as_of = pd.Timestamp(args.as_of) if args.as_of else data_as_of
        bundle, card = train_model(add_features(table), as_of, args.cost_miss, args.cost_check)
        path = save(bundle, card, source, args.by)
        print(f"Saved candidate {card['version']} to models/{path.name} with its card. It is not live yet.")
    elif args.command == "promote":
        promote(args.version, args.approved_by)
    else:
        live = (MODELS / "active.json").exists() and active_version()
        cards = sorted(MODELS.glob("m*.json")) if MODELS.exists() else []
        if not cards:
            print("No models yet. Train one first.")
        for path in cards:
            c = json.loads(path.read_text(encoding="utf-8"))
            mark = "LIVE" if c["version"] == live else "    "
            print(f"{mark} {c['version']}  as of {c['as_of']}  {c['model_type']:<20} threshold {c['threshold']:.2f}  "
                  f"test recall {c['test']['recall']:.2f}  trained by {c['trained_by']}")


if __name__ == "__main__":
    import late_model   # run the imported copy, so saved models point to late_model.neg_cost, not __main__
    late_model.main()

Step 2: Train a candidate

  1. Run, with your own first name after --by:

    python projects/late-delivery/late_model.py train --by alex

What success looks like (it takes a few seconds):

As of 2026-10-09: 3254 deliveries with a known outcome. Test split at 2026-07-04: train 2396, test 815.
  Average precision on 5 time-ordered folds: logistic_regression 0.44, gradient_boosting 0.39. Chosen: logistic_regression
  Test (815 deliveries, 128 late): AUC 0.804, recall 0.406, precision 0.423, 9.0 flags/week, cost 25,150 EUR vs 32,000 EUR flagging nothing
  Final model refit on all 3254 known deliveries. Threshold 0.19
Saved candidate m20261009 to models/m20261009.joblib with its card. It is not live yet.
  1. Read it like Unit 2's output. The test period starts on 4 July and is harder than training: it holds the slow route. Logistic regression wins again. At the cost-based threshold, the recipe caught 41% of late deliveries with about 9 flags a week, and cost 25,150 EUR against 32,000 EUR for flagging nothing. The final model then learned from all 3,254 known outcomes; its threshold is 0.19.
  2. Open models/m20261009.json. Find trained_by, test, reference and sha256. That is the model card.

Step 3: Promote it, the wrong way and the right way

  1. Try to approve your own model:

    python projects/late-delivery/late_model.py promote m20261009 --approved-by alex
    Refused: alex trained m20261009. A second person must approve it.
  2. Now with a second person's name. On a team, that person runs this command after reading the card:

    python projects/late-delivery/late_model.py promote m20261009 --approved-by sam
    m20261009 is live, approved by sam. Previous: none.
  3. List the models:

    python projects/late-delivery/late_model.py list
    LIVE m20261009  as of 2026-10-09  logistic_regression  threshold 0.19  test recall 0.41  trained by alex

Promotion also refuses a model whose test cost is not below "flag nothing". Every promotion is appended to models/promotions.jsonl. A rollback is the same command with the previous version.

What the code does

Part What it does
add_features Late from the two dates (open deliveries stay unlabelled), lead days, weekday
known_as_of, open_as_of What was known, and what was still open, on a given date. Every stage uses these, so no stage can peek at the future
train_model Time split, model choice on TimeSeriesSplit, cost-based threshold, one test, then a refit on all known outcomes and a readable explainer model
judge The numbers the business reads: recall, precision, flags per week, cost against "flag nothing"
save Writes the model with joblib and the card with the file's SHA-256 fingerprint
load_model Refuses a file whose fingerprint or scikit-learn version doesn't match its card
score, readable Score, flag, and the two strongest reasons from the explainer, in planner words
promote The approval gate: not the trainer, cheaper than flagging nothing; writes active.json and the promotion log
import late_model at the end Runs the module under its own name, so the saved model refers to late_model, not __main__, and the API can load it

If something goes wrong

What you see What it means What to do
No data/deliveries.json yet Stage 1 hasn't run Run sap_data.py --sample
Only N deliveries with a known outcome Too little history, for example on sandbox data Use --sample, or a larger extract
ImportError: cannot import name 'TunedThresholdClassifierCV' scikit-learn older than 1.5 pip install --upgrade scikit-learn, then train again
was trained with scikit-learn X, this is Y You upgraded scikit-learn after training Train again; a model only loads with the version it was trained with
does not match the fingerprint in its card The model file changed after training Don't use it. Train again
AttributeError: Can't get attribute 'neg_cost' on <module '__main__'> The last two lines of the script were changed Restore import late_model and late_model.main() at the end, and train again

Stage 3: API

Before you start: complete Set up your computer for this course, Set up for Unit 2 and Set up for Unit 6, and Stages 1 and 2 above. Building an AI API explains FastAPI, request models and contract tests.

The API puts the model behind a web address, so the worklist page, a Joule agent or another system can use it. It also does what a script can't: it filters by the user's shipping points, it logs every prediction and decision for monitoring and the KPI, and it refuses fields the model must never see.

sequenceDiagram
  participant P as Planner's browser
  participant A as app.py
  participant L as events.jsonl
  participant X as expedite_requests.csv
  Note over A,L: start: score open deliveries, log "scored" once per model
  P->>A: GET /api/worklist (X-User: omar)
  A-->>P: omar's deliveries, scores, reasons
  P->>A: POST /api/decisions (expedite)
  A->>A: planner? own shipping point? not decided yet?
  A->>L: decision event with cost
  A->>X: one line for SAP
  A-->>P: recorded

The endpoints:

Endpoint Who What it does
GET /health anyone Live model version, threshold, data date, open deliveries
GET /api/worklist any user Open deliveries in the user's shipping points, highest risk first; holdout deliveries without a score
POST /api/score any user Scores one delivery from its creation-time fields; any other field is refused
POST /api/decisions planners, own shipping points Records expedite or no action once per delivery; expedites go to exports/expedite_requests.csv

Three demo users stand in for real roles: pia (planner, shipping points 1010 and 1020), omar (planner, 1710) and vic (viewer, all three).

The holdout. in_holdout puts a fixed 20% of deliveries in the holdout, chosen from a fingerprint of the delivery number, so a delivery stays in or out across restarts. Holdout deliveries are scored and logged, but planners see no score, flag or reason. Stage 7 compares them with flagged deliveries that planners saw.

What you need

  • About 30 minutes. No cost.

Step 1: Save the API

  1. In projects/late-delivery, create app.py, paste the code below and save. It is long because the Stage 4 web page is inside it, as the text in PAGE.
"""Stages 3 and 4: the late-delivery API and a one-page worklist for planners.
It reads deliveries and the live model; it never writes to SAP. Expedites go to a file a planner carries out in SAP.

How to run (from your orchestrate-course folder):
    python projects/late-delivery/app.py                 # open http://127.0.0.1:8000
    python projects/late-delivery/app.py --port 8080     # another port if 8000 is taken
API documentation: http://127.0.0.1:8000/docs
"""
import argparse
import csv
import hashlib
import json
import os
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Literal

import pandas as pd
from fastapi import FastAPI, Header, HTTPException
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, ConfigDict, Field

from late_model import FEATURES, add_features, load_model, open_as_of, score
from sap_data import load_deliveries, parse_date

HERE = Path(__file__).parent
LOGS = Path(os.environ.get("LOG_DIR", HERE / "logs"))
EXPORTS = Path(os.environ.get("EXPORT_DIR", HERE / "exports"))
# Demo users. A real service reads the user and their shipping point authorizations from a token.
USERS = {"pia": {"role": "planner", "points": ["1010", "1020"]},
         "omar": {"role": "planner", "points": ["1710"]},
         "vic": {"role": "viewer", "points": ["1010", "1020", "1710"]}}
_lock = threading.Lock()


def now() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="milliseconds")


def log_event(event: dict) -> None:
    """Append one JSON line to logs/events.jsonl. Monitoring and the KPI read this file."""
    LOGS.mkdir(parents=True, exist_ok=True)
    with _lock, open(LOGS / "events.jsonl", "a", encoding="utf-8") as f:
        f.write(json.dumps({"ts": now(), **event}) + "\n")


def read_events() -> list[dict]:
    path = LOGS / "events.jsonl"
    if not path.exists():
        return []
    return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]


def in_holdout(delivery: str, share: float) -> bool:
    """A fixed, random-looking share of deliveries is scored but not flagged, so the KPI has a fair comparison."""
    return int(hashlib.sha256(delivery.encode()).hexdigest(), 16) % 100 < share * 100


def check_user(x_user: str | None) -> dict:
    if x_user not in USERS:
        raise HTTPException(401, f"Unknown user. Send the X-User header with one of: {', '.join(USERS)}")
    return {"name": x_user, **USERS[x_user]}


class DeliveryIn(BaseModel):
    """The fields the model may use, as an outbound delivery shows them at creation. Any other field is refused."""
    model_config = ConfigDict(extra="forbid")
    DeliveryDocument: str = Field(min_length=1, max_length=10)
    ShippingPoint: str
    ShippingType: str = "(blank)"
    ShippingCondition: str = "(blank)"
    DeliveryPriority: str = "(blank)"
    ProposedDeliveryRoute: str = "(blank)"
    CreationDate: str = Field(description="YYYY-MM-DD or /Date(ms)/")
    PlannedGoodsIssueDate: str = Field(description="YYYY-MM-DD or /Date(ms)/")
    HeaderGrossWeight: float = Field(ge=0)
    Items: int = Field(ge=1, le=999)
    TotalQuantity: float = Field(ge=0)


class Decision(BaseModel):
    delivery: str
    action: Literal["expedite", "no_action"]
    note: str = Field(default="", max_length=300)


def to_date(value: str) -> pd.Timestamp:
    ts = parse_date(value) if value.startswith("/Date(") else pd.to_datetime(value, errors="coerce")
    if pd.isna(ts):
        raise HTTPException(422, f"Not a date: {value}")
    return ts


def create_app(holdout_share: float = 0.2, expedite_cost: float = 50.0) -> FastAPI:
    table, as_of, source = load_deliveries()
    bundle, card = load_model()
    open_rows = open_as_of(add_features(table), as_of)
    scored = score(bundle, card, open_rows).merge(
        open_rows[["DeliveryDocument", "ShippingPoint", "ProposedDeliveryRoute", "PlannedGoodsIssueDate"]])
    scored["holdout"] = [in_holdout(d, holdout_share) for d in scored["DeliveryDocument"]]
    scored = scored.sort_values("score", ascending=False).reset_index(drop=True)

    # The prediction log: each open delivery once per model version. Monitoring and the KPI join outcomes to it.
    logged = {(e["delivery"], e["model"]) for e in read_events() if e["event"] == "scored"}
    for r in scored.itertuples():
        if (r.DeliveryDocument, card["version"]) not in logged:
            log_event({"event": "scored", "delivery": r.DeliveryDocument, "model": card["version"],
                       "score": float(r.score), "flag": bool(r.flag), "holdout": bool(r.holdout),
                       "point": r.ShippingPoint, "data_as_of": str(as_of.date())})
    decisions = {e["delivery"]: e for e in read_events() if e["event"] == "decision"}

    app = FastAPI(title="Late delivery predictor", version="1.0")

    @app.get("/", response_class=HTMLResponse)
    def home():
        return PAGE

    @app.get("/health")
    def health():
        return {"status": "ok", "model": card["version"], "threshold": card["threshold"],
                "data_as_of": str(as_of.date()), "source": source["source"], "open_deliveries": len(scored)}

    @app.get("/api/worklist")
    def worklist(only_flagged: bool = False, x_user: str | None = Header(None)):
        user = check_user(x_user)
        rows = []
        for r in scored[scored["ShippingPoint"].isin(user["points"])].itertuples():
            shown_flag = bool(r.flag) and not r.holdout          # holdout deliveries never show a flag
            if only_flagged and not shown_flag:
                continue
            rows.append({"delivery": r.DeliveryDocument, "shipping_point": r.ShippingPoint,
                         "route": r.ProposedDeliveryRoute, "planned_gi": str(r.PlannedGoodsIssueDate.date()),
                         "overdue": bool(r.PlannedGoodsIssueDate < as_of),   # planned date passed, no goods issue
                         "score": None if r.holdout else float(r.score), "flag": shown_flag,
                         "reasons": [] if r.holdout else r.reasons,
                         "decision": decisions.get(r.DeliveryDocument, {}).get("action")})
        return {"model": card["version"], "data_as_of": str(as_of.date()), "user": user["name"],
                "role": user["role"], "deliveries": rows}

    @app.post("/api/score")
    def score_one(body: DeliveryIn, x_user: str | None = Header(None)):
        """For other systems: score one delivery from its creation-time fields."""
        check_user(x_user)
        created, planned = to_date(body.CreationDate), to_date(body.PlannedGoodsIssueDate)
        if planned < created:
            raise HTTPException(422, "PlannedGoodsIssueDate is before CreationDate.")
        row = pd.DataFrame([{**body.model_dump(), "GrossWeight": body.HeaderGrossWeight,
                             "PlannedLeadDays": (planned - created).days,
                             "PlannedGIWeekday": planned.day_name()[:3]}])
        result = score(bundle, card, row[["DeliveryDocument"] + FEATURES]).iloc[0]
        return {"delivery": body.DeliveryDocument, "model": card["version"], "score": float(result["score"]),
                "flag": bool(result["flag"]), "reasons": result["reasons"]}

    @app.post("/api/decisions")
    def decide(body: Decision, x_user: str | None = Header(None)):
        """The planner's decision. The app records it; the planner carries out an expedite in SAP."""
        user = check_user(x_user)
        if user["role"] != "planner":
            raise HTTPException(403, "Only planners record decisions.")
        match = scored[scored["DeliveryDocument"] == body.delivery]
        if match.empty:
            raise HTTPException(404, "Not an open delivery in today's worklist.")
        if match.iloc[0]["ShippingPoint"] not in user["points"]:
            raise HTTPException(403, "This delivery belongs to a shipping point you are not responsible for.")
        if body.delivery in decisions:
            raise HTTPException(409, f"Already decided by {decisions[body.delivery]['user']}.")
        event = {"event": "decision", "delivery": body.delivery, "action": body.action, "user": user["name"],
                 "note": body.note, "cost": expedite_cost if body.action == "expedite" else 0.0,
                 "model": card["version"], "score": float(match.iloc[0]["score"]),
                 "holdout": bool(match.iloc[0]["holdout"])}
        log_event(event)
        decisions[body.delivery] = event
        if body.action == "expedite":
            EXPORTS.mkdir(parents=True, exist_ok=True)
            path = EXPORTS / "expedite_requests.csv"
            with _lock, open(path, "a", newline="", encoding="utf-8") as f:
                writer = csv.writer(f)
                if f.tell() == 0:
                    writer.writerow(["delivery", "shipping_point", "planned_gi", "requested_by", "note", "requested_at"])
                writer.writerow([body.delivery, match.iloc[0]["ShippingPoint"],
                                 str(match.iloc[0]["PlannedGoodsIssueDate"].date()), user["name"],
                                 "'" + body.note if body.note[:1] in ("=", "+", "-", "@", "\t", "\r") else body.note, now()])
        return {"recorded": True, **event}

    return app


PAGE = """<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Late delivery worklist</title>
<style>
 body{font-family:system-ui,sans-serif;margin:1rem;max-width:60rem}
 table{border-collapse:collapse;width:100%} th,td{text-align:left;padding:.35rem;border-bottom:1px solid #ddd;font-size:.95rem}
 .flag{color:#a40000;font-weight:600} button{margin-right:.3rem} .muted{color:#666}
 @media (max-width:40rem){td:nth-child(3),th:nth-child(3){display:none}}
</style></head><body>
<h1>Late delivery worklist</h1>
<p>User <select id="user"><option>pia</option><option>omar</option><option>vic</option></select>
 <label><input type="checkbox" id="only"> Only flagged</label> <button onclick="load()">Refresh</button></p>
<p id="info" class="muted"></p>
<table><thead><tr><th>Delivery</th><th>Point</th><th>Route</th><th>Planned GI</th><th>Score</th><th>Why</th><th>Decision</th></tr></thead>
<tbody id="rows"></tbody></table>
<script>
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
async function call(path, options = {}) {
  options.headers = {"X-User": document.getElementById("user").value, "Content-Type": "application/json"};
  const r = await fetch(path, options);
  const body = await r.json();
  if (!r.ok) { alert(body.detail || "Error " + r.status); return null; }
  return body;
}
async function load() {
  const only = document.getElementById("only").checked;
  const data = await call("/api/worklist?only_flagged=" + only);
  if (!data) return;
  document.getElementById("info").textContent =
    `Model ${data.model}, data as of ${data.data_as_of}, ${data.deliveries.length} deliveries for ${data.user} (${data.role}).`;
  document.getElementById("rows").innerHTML = data.deliveries.map(d => `<tr>
    <td>${esc(d.delivery)}</td><td>${esc(d.shipping_point)}</td><td>${esc(d.route)}</td>
    <td class="${d.overdue ? "flag" : ""}">${esc(d.planned_gi)}${d.overdue ? " OVERDUE" : ""}</td>
    <td class="${d.flag ? "flag" : ""}">${d.score === null ? "-" : d.score.toFixed(2)}${d.flag ? " CHECK" : ""}</td>
    <td>${esc(d.reasons.join(", "))}</td>
    <td>${d.decision ? esc(d.decision) : `<button onclick="decide(${esc(JSON.stringify(d.delivery))},'expedite')">Expedite</button>
      <button onclick="decide(${esc(JSON.stringify(d.delivery))},'no_action')">No action</button>`}</td></tr>`).join("");
}
async function decide(delivery, action) {
  const note = prompt(action === "expedite" ? "What will you do in SAP? (for example: move to express route)" : "Why no action?");
  if (note === null) return;
  if (await call("/api/decisions", {method: "POST", body: JSON.stringify({delivery, action, note})})) load();
}
document.getElementById("user").onchange = load;
document.getElementById("only").onchange = load;
load();
</script></body></html>"""


def main() -> None:
    parser = argparse.ArgumentParser(description="Stages 3 and 4: late-delivery API and worklist page.")
    parser.add_argument("--port", type=int, default=8000)
    parser.add_argument("--holdout", type=float, default=0.2, help="share of deliveries scored but never flagged")
    parser.add_argument("--expedite-cost", type=float, default=50.0, help="EUR recorded per expedite")
    args = parser.parse_args()
    import uvicorn
    app = create_app(args.holdout, args.expedite_cost)
    port = int(os.environ.get("PORT", args.port))
    host = "0.0.0.0" if "PORT" in os.environ else "127.0.0.1"   # on a platform, listen on all addresses
    print(f"Serving the worklist on http://{host}:{port}")
    uvicorn.run(app, host=host, port=port)


if __name__ == "__main__":
    main()

Step 2: Save and run the contract tests

  1. Create test_app.py, paste this and save:
"""Contract tests for the API: the business rules, not only the code.

How to run (from your orchestrate-course folder, after Stages 1 and 2):
    python projects/late-delivery/test_app.py
"""
import os
import sys
import tempfile
import warnings
from pathlib import Path

TMP = Path(tempfile.mkdtemp())
os.environ["LOG_DIR"], os.environ["EXPORT_DIR"] = str(TMP / "logs"), str(TMP / "exports")
sys.path.insert(0, str(Path(__file__).parent))

warnings.filterwarnings("ignore", message=".*httpx.*")   # a library notice, not a test result
from fastapi.testclient import TestClient  # noqa: E402

from app import create_app  # noqa: E402

client = TestClient(create_app(holdout_share=0.2))
results = []


def check(name: str, ok: bool) -> None:
    results.append(ok)
    print(f"  {'PASS' if ok else 'FAIL'}  {name}")


def worklist(user: str, **params):
    return client.get("/api/worklist", headers={"X-User": user}, params=params).json()["deliveries"]


def first(user: str, holdout: bool | None = None):
    for d in worklist(user):
        if holdout is None or (d["score"] is None) == holdout:
            if d["decision"] is None:
                return d
    return None


DELIVERY = {"DeliveryDocument": "90000001", "ShippingPoint": "1710", "ShippingType": "01", "ShippingCondition": "01",
            "DeliveryPriority": "02", "ProposedDeliveryRoute": "R17102", "CreationDate": "2026-10-05",
            "PlannedGoodsIssueDate": "2026-10-09", "HeaderGrossWeight": 420.0, "Items": 4, "TotalQuantity": 60}

print("Contract tests")
health = client.get("/health").json()
check("health names the live model and the data date", health["status"] == "ok" and health["model"].startswith("m"))
check("an unknown user gets 401", client.get("/api/worklist", headers={"X-User": "eve"}).status_code == 401)
check("pia sees only shipping points 1010 and 1020",
      {d["shipping_point"] for d in worklist("pia")} <= {"1010", "1020"})
check("omar sees only shipping point 1710", {d["shipping_point"] for d in worklist("omar")} <= {"1710"})
check("holdout deliveries show no score, flag or reasons",
      all(d["score"] is None and not d["flag"] and not d["reasons"] for d in worklist("vic") if d["score"] is None))
check("'only flagged' returns flagged deliveries only", all(d["flag"] for d in worklist("vic", only_flagged=True)))

scored = client.post("/api/score", headers={"X-User": "vic"}, json=DELIVERY)
check("score returns a score between 0 and 1", scored.status_code == 200 and 0 <= scored.json()["score"] <= 1)
leaky = {**DELIVERY, "ActualGoodsMovementDate": "2026-10-12"}
check("score refuses a field filled after goods issue (422)",
      client.post("/api/score", headers={"X-User": "vic"}, json=leaky).status_code == 422)
backwards = {**DELIVERY, "PlannedGoodsIssueDate": "2026-10-01"}
check("score refuses a planned date before creation (422)",
      client.post("/api/score", headers={"X-User": "vic"}, json=backwards).status_code == 422)

target = first("omar")
decide = lambda user, delivery, action="expedite": client.post(  # noqa: E731
    "/api/decisions", headers={"X-User": user}, json={"delivery": delivery, "action": action, "note": "test"})
check("a viewer cannot record a decision (403)", decide("vic", target["delivery"]).status_code == 403)
check("a planner cannot decide for another shipping point (403)", decide("pia", target["delivery"]).status_code == 403)
check("an unknown delivery gets 404", decide("omar", "99999999").status_code == 404)
check("the responsible planner can expedite", decide("omar", target["delivery"]).status_code == 200)
check("a delivery is decided only once (409)", decide("omar", target["delivery"], "no_action").status_code == 409)
exports = TMP / "exports" / "expedite_requests.csv"
check("the expedite goes to a file for SAP, not to SAP", exports.exists() and target["delivery"] in exports.read_text())

print(f"\n{sum(results)} of {len(results)} passed")
sys.exit(0 if all(results) else 1)
  1. Run it:

    python projects/late-delivery/test_app.py

What success looks like:

Contract tests
  PASS  health names the live model and the data date
  PASS  an unknown user gets 401
  PASS  pia sees only shipping points 1010 and 1020
  PASS  omar sees only shipping point 1710
  PASS  holdout deliveries show no score, flag or reasons
  PASS  'only flagged' returns flagged deliveries only
  PASS  score returns a score between 0 and 1
  PASS  score refuses a field filled after goods issue (422)
  PASS  score refuses a planned date before creation (422)
  PASS  a viewer cannot record a decision (403)
  PASS  a planner cannot decide for another shipping point (403)
  PASS  an unknown delivery gets 404
  PASS  the responsible planner can expedite
  PASS  a delivery is decided only once (409)
  PASS  the expedite goes to a file for SAP, not to SAP

15 of 15 passed

Most of these tests guard business rules: who sees what, who may decide, one decision per delivery, the leak guard, and the fact that expedites become a file, not an SAP change. Keep them passing after every change. The tests write their logs to a temporary folder, so they don't touch your real log.

Step 3: Start the API and call it

  1. Start it:

    python projects/late-delivery/app.py
    Serving the worklist on http://127.0.0.1:8000
    INFO:     Started server process [12345]
    ...
    INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

    The terminal is now busy running the server. Leave it open.

  2. Open a second terminal (Terminal > New Terminal), turn on .venv again, and ask for Pia's flagged deliveries:

    • Windows (PowerShell):

      Invoke-RestMethod "http://127.0.0.1:8000/api/worklist?only_flagged=true" -Headers @{"X-User"="pia"} | ConvertTo-Json -Depth 5
    • macOS / Linux:

      curl -s -H "X-User: pia" "http://127.0.0.1:8000/api/worklist?only_flagged=true"

    You get JSON with two deliveries, the first one like this:

    {"delivery":"80003276","shipping_point":"1010","route":"R10101","planned_gi":"2026-10-11","overdue":false,"score":0.327,"flag":true,"reasons":["high weight","shipping condition 01"],"decision":null}
  3. Score a delivery as another system would. Then try again with a field that is only filled after goods issue:

    • Windows (PowerShell):

      $body = '{"DeliveryDocument":"90000001","ShippingPoint":"1710","ProposedDeliveryRoute":"R17102","CreationDate":"2026-10-05","PlannedGoodsIssueDate":"2026-10-09","HeaderGrossWeight":420,"Items":4,"TotalQuantity":60}'
      Invoke-RestMethod http://127.0.0.1:8000/api/score -Method Post -Headers @{"X-User"="vic"} -ContentType "application/json" -Body $body
    • macOS / Linux:

      curl -s -X POST -H "X-User: vic" -H "Content-Type: application/json" \
        -d '{"DeliveryDocument":"90000001","ShippingPoint":"1710","ProposedDeliveryRoute":"R17102","CreationDate":"2026-10-05","PlannedGoodsIssueDate":"2026-10-09","HeaderGrossWeight":420,"Items":4,"TotalQuantity":60}' \
        http://127.0.0.1:8000/api/score
    {"delivery":"90000001","model":"m20261009","score":0.299,"flag":true,"reasons":["goods issue on Fri","shipping point 1710"]}

    Now add "ActualGoodsMovementDate":"2026-10-12", after the first { in the body and send it again. The answer is status 422 with extra_forbidden: the API refuses the field that leaked the answer in Unit 2. In PowerShell, the 422 shows as a red error message; that is the expected result.

  4. Open http://127.0.0.1:8000/docs in a browser. FastAPI lists every endpoint with its fields. You can try them there with Try it out.

  5. Look at projects/late-delivery/logs/events.jsonl. It starts with one scored line per open delivery, for example:

    {"ts": "2026-10-11T06:29:18.333+00:00", "event": "scored", "delivery": "80003279", "model": "m20261009", "score": 0.536, "flag": true, "holdout": false, "point": "1710", "data_as_of": "2026-10-09"}
  6. Leave the server running for Stage 4, or stop it with Ctrl+C.

What the code does

Part What it does
USERS Demo users with a role and the shipping points they are responsible for
log_event, read_events Append one JSON line per event; read the log back at start-up
in_holdout A fixed, random-looking 20% of deliveries, from a SHA-256 fingerprint of the delivery number
DeliveryIn The request model for /api/score; extra="forbid" refuses any field the model must not see
create_app Loads data and the live model, scores the open deliveries once and logs them, then defines the endpoints
worklist Filters by the user's shipping points; hides score and reasons for holdout deliveries; marks overdue ones
decide The checks, in order: planner role, open delivery, own shipping point, not decided yet; then the log line and the expedite file
PAGE The Stage 4 web page
main Listens on PORT and all addresses when the platform sets PORT, otherwise on your computer only

If something goes wrong

What you see What it means What to do
No model is live yet Stage 2's promotion hasn't happened Promote a model with a second name
ModuleNotFoundError: No module named 'httpx', or RuntimeError: ... requires the httpx2 package, in the tests The test client's library is missing Run pip install "fastapi[standard]" as in Set up for Unit 6
address already in use or [Errno 10048] Port 8000 is taken Stop the other server with Ctrl+C, or use --port 8080 and that port in your calls
{"detail":"Unknown user..."} The X-User header is missing or misspelled Send pia, omar or vic
curl or Invoke-RestMethod can't connect The server isn't running Check the first terminal shows Uvicorn running
A test fails after you changed app.py A business rule broke Read the failing line; fix the code, not the test

Stage 4: UI

Before you start: complete Set up your computer for this course, Set up for Unit 2 and Set up for Unit 6, and Stages 1 to 3 above.

The simplest thing a planner can try is a web page. app.py serves one at /: a user picker, the worklist and two buttons per delivery. It is plain HTML and a little JavaScript, with no build step.

What you need

  • The API from Stage 3, running. About 15 minutes.

Step 1: Work the list as Omar

  1. Open http://127.0.0.1:8000 in a browser.
  2. Set User to omar. The line under the buttons says Model m20261009, data as of 2026-10-09, 10 deliveries for omar (planner).
  3. Read the table. Four deliveries show a red score with CHECK. Delivery 80003236 also shows OVERDUE next to its planned date: the date has passed without a goods issue, so it is already late whatever the score says. Some rows show - instead of a score: they are in the holdout.
  4. Choose Expedite on the top delivery, 80003279. Type what you would do in SAP, for example move to earlier truck, and choose OK. The buttons are replaced by expedite.
  5. Open projects/late-delivery/exports/expedite_requests.csv. It has one line with the delivery, the shipping point, the planned date, who asked, the note and the time. That file is the planner's to-do list in SAP.

Step 2: Check the rules from the page

  1. Set User to pia. You see only shipping points 1010 and 1020.
  2. Set User to vic, the viewer. You see all three shipping points. Choose No action on any delivery and type a reason. An alert says Only planners record decisions.
  3. Tick Only flagged to see only the flagged deliveries.

Step 3: Try it on a phone-sized window

  1. Make the browser window narrow. The route column disappears on small screens and the rest stays usable. Planners often check the list on a tablet on the warehouse floor; check it before a demo.

What the page does

Part What it does
call Sends a request with the chosen user in X-User; shows the API's error message if one comes back
load Gets the worklist and builds the table: score, CHECK, OVERDUE, reasons, buttons or the decision
decide Asks for a note and records the decision
esc Escapes text from the data before showing it, so a field value can't inject HTML

If something goes wrong

What you see What it means What to do
The browser can't reach the site The server isn't running Start it as in Stage 3, Step 3
The table is empty The user has no open deliveries, or Only flagged is ticked Untick it, or switch user
Buttons do nothing An error in the browser Press F12, open Console and read the message; check you saved the whole app.py
Already decided by ... Someone recorded a decision for that delivery Expected: one decision per delivery

Stage 5: Deployment

Before you start: complete Set up your computer for this course, Set up for Unit 2 and Set up for Unit 6, and Stages 1 to 4 above. Deploying AI apps on SAP BTP shows the full Cloud Foundry routine on the free BTP trial, and the Unit 6 setup installs the cf command.

Two decisions shape the deployment:

  1. The model travels as a file, with the same library versions. The app loads models/m20261009.joblib and refuses it if the scikit-learn version differs. So requirements.txt pins exactly the versions you trained with, and the models/ and data/ folders are uploaded with the app.
  2. Logs on a container are temporary. Files written inside a Cloud Foundry container don't survive a restart, and each instance has its own. For a demo on the trial that is acceptable; for real use, events go to a database.

What you need

  • About 20 minutes for Steps 1 and 2, on your computer. Free.
  • For Step 3: your SAP BTP trial and the cf command from the Unit 6 setup.

Step 1: Create the deployment files

  1. Find your versions. In the terminal:

    pip show fastapi uvicorn numpy pandas scikit-learn

    Note each Version: line.

  2. In projects/late-delivery, create requirements.txt with those versions. These are the ones we tested on 11 October 2026; replace them with yours:

    fastapi==0.143.0
    uvicorn==0.54.0
    numpy==2.5.4
    pandas==3.0.6
    scikit-learn==1.9.1
  3. Create runtime.txt. The Cloud Foundry Python buildpack reads the Python version from it; 3.13.x means the newest 3.13 it has:

    python-3.13.x
  4. Create manifest.yml. Use spaces, not tabs:

    ---
    applications:
    - name: late-delivery
      path: .
      memory: 512M
      instances: 1
      random-route: true
      buildpacks:
      - python_buildpack
      command: python app.py
      health-check-type: http
      health-check-http-endpoint: /health
  5. Create .cfignore, what must never be uploaded (type the dot at the start of the name):

    .env
    .venv/
    __pycache__/
    *.pyc
    logs/
    exports/
    reports/

    data/ and models/ are uploaded on purpose: the app needs both. With --sample data that is safe.

Step 2: Run it the way the cloud will

The Cloud Foundry Python buildpack has no default start command, and its documentation says the app should listen on the port in PORT. app.py reads PORT and then listens on all addresses.

  1. Start the app with PORT set:

    • Windows (PowerShell):

      $env:PORT = "8080"; python projects/late-delivery/app.py
    • macOS / Linux:

      PORT=8080 python projects/late-delivery/app.py
    Serving the worklist on http://0.0.0.0:8080
  2. In a second terminal, check the health endpoint the platform will call:

    • Windows (PowerShell): Invoke-RestMethod http://127.0.0.1:8080/health
    • macOS / Linux: curl -s http://127.0.0.1:8080/health
    {"status":"ok","model":"m20261009","threshold":0.192,"data_as_of":"2026-10-09","source":"sample","open_deliveries":46}
  3. Stop it with Ctrl+C. In PowerShell, run Remove-Item Env:PORT so later runs use port 8000 again.

Our test, with a fresh environment built only from requirements.txt, used about 200 MB of memory, inside the 512 MB in the manifest.

Step 3: Push to the BTP trial (sketch)

  1. Check you are logged in: cf target shows your trial org and space.

  2. Go into the project folder and push:

    cd projects/late-delivery
    cf push
  3. When it finishes, cf app late-delivery shows the route under routes:. Open https://<route>/health, then https://<route>/.

  4. Go back: cd ../...

  5. When you are done, stop it so it doesn't use your trial's memory: cf stop late-delivery.

What the files do

File What it does
requirements.txt Tells the buildpack this is a Python app and pins the libraries, so the model loads
runtime.txt Picks the Python version
manifest.yml Name, memory, start command and an HTTP health check on /health
.cfignore Keeps secrets, the virtual environment, logs, exports and reports out of the upload

If something goes wrong

What you see What it means What to do
The app crashes on start; cf logs late-delivery --recent shows was trained with scikit-learn The cloud installed another scikit-learn version Pin the exact version you trained with in requirements.txt
cf logs shows No model is live yet or No data/deliveries.json models/ or data/ wasn't uploaded Check .cfignore doesn't list them; run Stages 1 and 2 before pushing
insufficient resources or a memory quota error The trial space has no memory left Stop other apps with cf stop
cf is not recognized The Cloud Foundry CLI isn't installed Repeat the CLI step of Set up for Unit 6

Stage 6: Evaluation

Before you start: complete Set up your computer for this course, Set up for Unit 2 and Set up for Unit 6, and Stages 1 and 2 above. Observability for AI systems covers logs and alerts in general; this stage is about the model.

Stage 2's test answers "how good was this recipe on one past period?". A live model raises three more questions, and this stage answers each with a script:

Question Script Answer in the sample
How would it have done if it had run in the last months? Is retraining worth it? evaluate.py (backtest) Monthly retraining caught 64 late deliveries instead of 47
Is the live model still behaving as tested? monitor.py (weekly) A model frozen in June raises three alarms by late September
What do we do when it isn't? The retraining plan below Train, backtest, approve, with a rollback ready
flowchart LR
  W[Weekly: monitor.py] -->|no alarm| W
  W -->|alarm| T[Train candidate<br/>late_model.py train]
  S[Monthly schedule] --> T
  T --> E[Backtest<br/>evaluate.py]
  E --> R{Second person<br/>reviews card and report}
  R -->|approve| P[promote]
  R -->|reject| K[Keep live model]
  P --> W
  P -.->|problem| B[Roll back:<br/>promote previous]

What you need

  • About 30 minutes. No cost.

Step 1: Save and run the backtest

The backtest replays July, August and September twice. The frozen model is trained as of 30 June and used for all three months. The retrained model is trained at the end of each month, on what was known then, and used for the next month. Both are judged on the same deliveries.

  1. Create evaluate.py in the project folder, paste this and save:
"""Stage 6a: backtest. Replay the last months as if you had run the model live, two ways:
a model frozen at the start, and a model retrained at the end of every month on what was known then.

How to run (from your orchestrate-course folder):
    python projects/late-delivery/evaluate.py              # the last 3 complete months
    python projects/late-delivery/evaluate.py --months 4
"""
import argparse
from pathlib import Path

import pandas as pd

from late_model import add_features, judge, md_table, train_model
from sap_data import load_deliveries

HERE = Path(__file__).parent


def main() -> None:
    parser = argparse.ArgumentParser(description="Backtest: frozen model vs monthly retraining.")
    parser.add_argument("--months", type=int, default=3, help="how many complete months to replay")
    parser.add_argument("--cost-miss", type=float, default=250.0)
    parser.add_argument("--cost-check", type=float, default=50.0)
    args = parser.parse_args()

    table, as_of, source = load_deliveries()
    df = add_features(table)
    last_complete = as_of.to_period("M") - 1
    months = [last_complete - i for i in range(args.months - 1, -1, -1)]
    origins = [(m - 1).to_timestamp(how="end").normalize() for m in months]   # last day of the month before
    print(f"Backtest on {source['source']} data as of {as_of.date()}: months {months[0]} to {months[-1]}.")
    print(f"Frozen model trained as of {origins[0].date()}; retrained model trained at the end of each prior month.\n")

    frozen, frozen_card = train_model(df, origins[0], args.cost_miss, args.cost_check, quiet=True)
    rows, totals = [], {"frozen": [], "retrained": [], "nothing": 0.0}
    for month, origin in zip(months, origins):
        retrained, card = (frozen, frozen_card) if origin == origins[0] else \
            train_model(df, origin, args.cost_miss, args.cost_check, quiet=True)
        created = df["CreationDate"].dt.to_period("M") == month
        test = df[created & (df["ActualGoodsMovementDate"] <= as_of)].copy()   # outcome known by today
        test["Late"] = test["Late"].astype(int)
        for name, bundle in (("frozen", frozen), ("retrained", retrained)):
            r = judge(bundle["model"], test, args.cost_miss, args.cost_check)
            totals[name].append(r)
            rows.append({"month": str(month), "model": name, "trained_as_of": str((origins[0] if name == "frozen" else origin).date()),
                         "deliveries": r["rows"], "late": r["late"], "auc": r["auc"], "recall": r["recall"],
                         "precision": r["precision"], "flags/week": r["flags_per_week"],
                         "cost EUR": int(r["cost"]), "flag nothing EUR": int(r["cost_flag_nothing"])})
        totals["nothing"] += totals["frozen"][-1]["cost_flag_nothing"]

    report = pd.DataFrame(rows)
    print(report.to_string(index=False))
    summary = []
    for name in ("frozen", "retrained"):
        cost = sum(r["cost"] for r in totals[name])
        caught, late = sum(r["caught"] for r in totals[name]), sum(r["late"] for r in totals[name])
        summary.append(f"{name:<10} caught {caught} of {late} late, cost {cost:,.0f} EUR "
                       f"({1 - cost / totals['nothing']:.0%} less than flagging nothing: {totals['nothing']:,.0f} EUR)")
    print("\nOver all months:\n  " + "\n  ".join(summary))

    out = HERE / "reports" / "backtest.md"
    out.parent.mkdir(exist_ok=True)
    out.write_text(f"# Backtest: frozen vs monthly retraining\n\nData: {source['source']}, as of {as_of.date()}. "
                   f"Costs: miss {args.cost_miss:.0f} EUR, check {args.cost_check:.0f} EUR.\n\n"
                   + md_table(report) + "\n\n" + "\n".join(f"- {s}" for s in summary) + "\n",
                   encoding="utf-8")
    print(f"\nSaved reports/{out.name}")


if __name__ == "__main__":
    main()
  1. Run it (it trains three models, so it takes about 15 seconds):

    python projects/late-delivery/evaluate.py

What success looks like:

Backtest on sample data as of 2026-10-09: months 2026-07 to 2026-09.
Frozen model trained as of 2026-06-30; retrained model trained at the end of each prior month.

  month     model trained_as_of  deliveries  late   auc  recall  precision  flags/week  cost EUR  flag nothing EUR
2026-07    frozen    2026-06-30         281    41 0.791   0.317      0.361         8.4      8800             10250
2026-07 retrained    2026-06-30         281    41 0.791   0.317      0.361         8.4      8800             10250
2026-08    frozen    2026-06-30         276    43 0.809   0.419      0.474         8.9      8150             10750
2026-08 retrained    2026-07-31         276    43 0.814   0.488      0.382        12.8      8250             10750
2026-09    frozen    2026-06-30         258    44 0.817   0.364      0.571         6.8      8400             11000
2026-09 retrained    2026-08-31         258    44 0.827   0.682      0.455        15.9      6800             11000

Over all months:
  frozen     caught 47 of 128 late, cost 25,350 EUR (21% less than flagging nothing: 32,000 EUR)
  retrained  caught 64 of 128 late, cost 23,850 EUR (25% less than flagging nothing: 32,000 EUR)

Saved reports/backtest.md
  1. Read it:
    • July is identical for both, because both models were trained on 30 June.
    • In September the frozen model caught 16 of 44 late deliveries (recall 0.36); the retrained one caught 30 (0.68). The slow route started in mid-July. The model retrained on 31 August had seen six weeks of it; the frozen one had never seen it.
    • Retraining raised the flag volume from about 7 to 16 a week in September. That is the guardrail question for planners: can they handle 16 checks a week? The cost still fell, from 8,400 to 6,800 EUR.
    • Over three months, monthly retraining caught 64 instead of 47 late deliveries, and saved 25% of the "flag nothing" cost instead of 21%.

The report is saved as reports/backtest.md for your portfolio.

Step 2: Save the monitoring script

Monitoring runs every week on deliveries created after the model's training date and at least 14 days old, so most outcomes are known. It runs five kinds of checks against the model card:

Check Alarm when Why it matters
Recall vs test More than 0.15 below the test recall The model misses more late deliveries than promised
Flags per week vs test Below half or above 1.5 times the test rate Planners get too few or too many flags
Late share vs reference More than 8 points above the last 90 days of training The world got worse, and the threshold may be wrong
Late share per route A route moves 15 points or more (8 deliveries or more) A carrier or route changed
Codes unseen in training Over 5% of deliveries A new route or shipping point: handle_unknown="ignore" scores it as if it didn't exist, silently

The last check deserves a second look. In Unit 2, OneHotEncoder(handle_unknown="ignore") stopped a new route from crashing the scoring. The price is silence: the model treats a new route as "no route" and scores it like an average delivery. Only monitoring notices.

  1. Create monitor.py, paste this and save:
"""Stage 6b: weekly monitoring. Compare a model's recent weeks with what was normal when it was trained,
and say whether it needs retraining.

How to run (from your orchestrate-course folder):
    python projects/late-delivery/monitor.py                     # the live model
    python projects/late-delivery/monitor.py --model m20260630   # any trained model
"""
import argparse
from pathlib import Path

import pandas as pd

from late_model import CATEGORICAL, active_version, add_features, judge, load_model, md_table
from sap_data import load_deliveries

HERE = Path(__file__).parent
MATURITY_DAYS = 14   # most deliveries ship within two weeks of creation; younger ones would bias the window
LIMITS = {"recall_drop": 0.15, "flag_ratio": (0.5, 1.5), "late_share_rise": 0.08, "route_shift": 0.15,
          "unseen_share": 0.05, "min_route_rows": 8}


def main() -> None:
    parser = argparse.ArgumentParser(description="Weekly monitoring of a late-delivery model.")
    parser.add_argument("--model", help="model version; default: the live model")
    parser.add_argument("--weeks", type=int, default=4, help="size of the window to check")
    args = parser.parse_args()

    table, as_of, source = load_deliveries()
    bundle, card = load_model(args.model or active_version())
    ref, test = card["reference"], card["test"]
    end = as_of - pd.Timedelta(days=MATURITY_DAYS)
    start = max(pd.Timestamp(card["as_of"]) + pd.Timedelta(days=1), end - pd.Timedelta(weeks=args.weeks))
    df = add_features(table)
    window = df[(df["CreationDate"] >= start) & (df["CreationDate"] <= end) & df["Late"].notna()].copy()
    print(f"Monitoring {card['version']} (trained as of {card['as_of']}) on {source['source']} data as of {as_of.date()}.")
    if start > end or len(window) < 50:
        print(f"Only {len(window)} deliveries with a known outcome were created after this model was trained "
              f"and are at least {MATURITY_DAYS} days old. Nothing to judge yet; run again next week.")
        return
    window["Late"] = window["Late"].astype(int)
    print(f"Window: deliveries created {start.date()} to {end.date()}, outcome known: {len(window)}.\n")

    checks = []
    now = judge(bundle["model"], window, card["costs"]["miss"], card["costs"]["check"])
    checks.append(("Recall vs test", f"{now['recall']:.2f} vs {test['recall']:.2f}",
                   now["recall"] < test["recall"] - LIMITS["recall_drop"]))
    ratio = now["flags_per_week"] / max(test["flags_per_week"], 0.1)
    checks.append(("Flags per week vs test", f"{now['flags_per_week']} vs {test['flags_per_week']}",
                   not LIMITS["flag_ratio"][0] <= ratio <= LIMITS["flag_ratio"][1]))
    share = window["Late"].mean()
    checks.append(("Late share vs reference", f"{share:.0%} vs {ref['late_share']:.0%}",
                   share > ref["late_share"] + LIMITS["late_share_rise"]))
    for route, group in window.groupby("ProposedDeliveryRoute"):
        if route in ref["late_share_by_route"] and len(group) >= LIMITS["min_route_rows"]:
            before, after = ref["late_share_by_route"][route], group["Late"].mean()
            if abs(after - before) >= LIMITS["route_shift"]:
                checks.append((f"Route {route} late share", f"{after:.0%} vs {before:.0%} ({len(group)} deliveries)", True))
    unseen = pd.Series(False, index=window.index)
    new_codes = []
    for column in CATEGORICAL:
        is_new = ~window[column].astype(str).isin(ref["categories"][column])
        unseen |= is_new
        new_codes += [f"{column}={v}" for v in sorted(window.loc[is_new, column].astype(str).unique())]
    checks.append(("Deliveries with codes unseen in training",
                   f"{unseen.mean():.0%}" + (f" ({', '.join(new_codes)})" if new_codes else ""),
                   unseen.mean() > LIMITS["unseen_share"]))

    report = pd.DataFrame([{"check": c, "value": v, "status": "ALARM" if a else "ok"} for c, v, a in checks])
    print(report.to_string(index=False))
    alarms = [c for c, _, a in checks if a]
    verdict = ("RETRAIN RECOMMENDED: " + "; ".join(alarms)) if alarms else "No alarms. Keep the model; check again next week."
    print(f"\n{verdict}")
    if alarms:
        print("Plan: train a candidate as of today, run evaluate.py, then a second person promotes it if it is better.")
    out = HERE / "reports" / f"monitor_{card['version']}.md"
    out.parent.mkdir(exist_ok=True)
    out.write_text(f"# Monitoring {card['version']}\n\nData as of {as_of.date()}; window {start.date()} to {end.date()}, "
                   f"{len(window)} deliveries.\n\n{md_table(report)}\n\n**{verdict}**\n", encoding="utf-8")
    print(f"Saved reports/{out.name}")


if __name__ == "__main__":
    main()

Step 3: Monitor a model that went live in June

Pretend the model that went live on 1 July was trained on 30 June. It is now October.

  1. Train it as of 30 June. Don't promote it:

    python projects/late-delivery/late_model.py train --as-of 2026-06-30 --by alex
    As of 2026-06-30: 2368 deliveries with a known outcome. Test split at 2026-04-23: train 1731, test 594.
      Average precision on 5 time-ordered folds: logistic_regression 0.42, gradient_boosting 0.36. Chosen: logistic_regression
      Test (594 deliveries, 84 late): AUC 0.82, recall 0.512, precision 0.443, 10.3 flags/week, cost 15,100 EUR vs 21,000 EUR flagging nothing
      Final model refit on all 2368 known deliveries. Threshold 0.28
    Saved candidate m20260630 to models/m20260630.joblib with its card. It is not live yet.
  2. Monitor it:

    python projects/late-delivery/monitor.py --model m20260630

What success looks like:

Monitoring m20260630 (trained as of 2026-06-30) on sample data as of 2026-10-09.
Window: deliveries created 2026-08-28 to 2026-09-25, outcome known: 245.

                                   check                             value status
                          Recall vs test                      0.32 vs 0.51  ALARM
                  Flags per week vs test                       6.8 vs 10.3     ok
                 Late share vs reference                        16% vs 14%     ok
                 Route R17102 late share         88% vs 14% (8 deliveries)  ALARM
Deliveries with codes unseen in training 8% (ProposedDeliveryRoute=R17103)  ALARM

RETRAIN RECOMMENDED: Recall vs test; Route R17102 late share; Deliveries with codes unseen in training
Plan: train a candidate as of today, run evaluate.py, then a second person promotes it if it is better.
Saved reports/monitor_m20260630.md
  1. Read it. Recall fell from 0.51 in testing to 0.32. Route R17102 was late 88% of the time in the window against 14% in the reference: only 8 deliveries, so check by hand, but the size of the jump is clear. And 8% of deliveries used route R17103, which the model has never seen. Three alarms, one recommendation: retrain.

  2. Monitor the live model:

    python projects/late-delivery/monitor.py
    Monitoring m20261009 (trained as of 2026-10-09) on sample data as of 2026-10-09.
    Only 0 deliveries with a known outcome were created after this model was trained and are at least 14 days old. Nothing to judge yet; run again next week.

    That is a valid, empty result. The live model was trained on every known outcome; nothing newer exists yet. In production you would run this every Monday, for example as a scheduled job.

The retraining plan

Write this into the model's run book, and agree it with the process owner:

Item Plan
Schedule Retrain once a month on all known outcomes, the backtest showed it pays
Trigger Retrain early when monitor.py raises an alarm
Candidate late_model.py train --by <name>
Evidence The model card, reports/backtest.md and the latest monitoring report
Gate A second person promotes it; the code refuses the trainer and a model no cheaper than "flag nothing"
Rollback late_model.py promote <previous version> --approved-by <name>; the previous version is in models/active.json
Keep Every model file and card for at least a year; the promotion log for audits

What the code does

Part What it does
evaluate.py origins The last day of each month before the test month; train_model uses only outcomes known on that day
evaluate.py test rows Deliveries created in the month whose outcome is known by the data date
monitor.py window Created after the model's training date, at least MATURITY_DAYS old, outcome known
LIMITS The alarm thresholds in one place; agree them with the model owner
Checks Recall and flags against the card's test; late share against the card's reference; route shifts; codes the card has never seen
Reports reports/backtest.md and reports/monitor_<version>.md, in Markdown for the portfolio

If something goes wrong

What you see What it means What to do
No model card m20260630.json The June model wasn't trained Run Step 3.1 first
Nothing to judge yet for every model The data has no outcomes newer than the model Expected for a model trained today; use an older --as-of to practise
Different numbers Different library versions, or data you changed Small differences are fine; rerun Stage 1 with --sample to reset the data
The backtest is slow Each month trains a model with cross-validation Normal: about 15 seconds on a laptop

Stage 7: Business KPI

Before you start: complete Set up your computer for this course, Set up for Unit 2 and Set up for Unit 6, and Stages 1 to 6 above.

The KPI comes from the system's own log joined with outcomes from SAP, not from a survey.

KPI Computed from
On-time goods issue, before and after Outcomes in the data: 13 weeks before go-live, and every scored delivery since
Late share, flagged and shown vs flagged in holdout scored events (flag, holdout) joined with outcomes
Late deliveries avoided (estimate) (holdout late share − shown late share) × flagged deliveries shown
Expedite spend, and spend per late delivery avoided decision events with their cost
Flags shown per week scored events with a flag outside the holdout

The made-up data stops on 9 October, so there are no real outcomes after your clicks in Stage 4. To see the arithmetic now, --simulate replays July to September: the June model scores each delivery at creation, planners expedite 80% of the flags they see, and an expedite turns half of the truly late deliveries on time. Those two rates are assumptions written at the top of kpi.py. The simulated report shows how the KPI is computed, never what the worklist achieves.

What you need

  • Stages 1 to 6 done. About 20 minutes. No cost.

Step 1: Save the KPI script

  1. Create kpi.py in the project folder, paste this and save:
"""Stage 7: the business KPI, computed from the app's own log joined with outcomes from SAP data.

How to run (from your orchestrate-course folder):
    python projects/late-delivery/kpi.py --simulate   # replay July to September with made-up planner actions
    python projects/late-delivery/kpi.py              # your real log (logs/events.jsonl) and data/deliveries.json

KPIs: on-time goods issue rate, and expedite spend per late delivery avoided.
The fair comparison is flagged deliveries shown to planners vs flagged deliveries in the holdout.
"""
import argparse
import json
from pathlib import Path

import numpy as np
import pandas as pd

from app import in_holdout
from late_model import add_features, md_table, score, train_model
from sap_data import load_deliveries

HERE = Path(__file__).parent
ASSUMED_SAVE_RATE = 0.5      # SIMULATION ONLY: share of truly late deliveries an expedite turns on time. Made up.
ASSUMED_ACTION_RATE = 0.8    # SIMULATION ONLY: share of shown flags a planner expedites. Made up.


def simulate(df: pd.DataFrame, as_of: pd.Timestamp, go_live: pd.Timestamp, holdout: float, cost: float) -> Path:
    """Write a made-up log: the model scores each delivery at creation, planners act, outcomes follow."""
    bundle, card = train_model(df, go_live - pd.Timedelta(days=1), quiet=True)
    period = df[(df["CreationDate"] >= go_live) & (df["CreationDate"] <= as_of - pd.Timedelta(days=14))
                & df["Late"].notna()]
    scored = score(bundle, card, period).merge(period[["DeliveryDocument", "Late", "ShippingPoint"]])
    rng = np.random.default_rng(3)
    events = []
    for r in scored.itertuples():
        hold = in_holdout(r.DeliveryDocument, holdout)
        events.append({"event": "scored", "delivery": r.DeliveryDocument, "model": card["version"],
                       "score": float(r.score), "flag": bool(r.flag), "holdout": hold, "point": r.ShippingPoint})
        late = bool(r.Late)
        if r.flag and not hold and rng.random() < ASSUMED_ACTION_RATE:
            events.append({"event": "decision", "delivery": r.DeliveryDocument, "action": "expedite", "cost": cost})
            if late and rng.random() < ASSUMED_SAVE_RATE:
                late = False
        events.append({"event": "outcome", "delivery": r.DeliveryDocument, "late": late})
    path = HERE / "logs" / "events_simulated.jsonl"
    path.parent.mkdir(exist_ok=True)
    path.write_text("".join(json.dumps(e) + "\n" for e in events), encoding="utf-8")
    print(f"Wrote {len(scored)} simulated deliveries to logs/{path.name} (made up, for practice only).")
    return path


def main() -> None:
    parser = argparse.ArgumentParser(description="Stage 7: business KPI from the log.")
    parser.add_argument("--simulate", action="store_true", help="replay July to September with made-up actions")
    parser.add_argument("--go-live", default="2026-07-01", help="go-live date for --simulate")
    parser.add_argument("--baseline-weeks", type=int, default=13, help="weeks before go-live used as 'before'")
    parser.add_argument("--holdout", type=float, default=0.2)
    parser.add_argument("--expedite-cost", type=float, default=50.0)
    args = parser.parse_args()

    table, as_of, _ = load_deliveries()
    df = add_features(table)
    if args.simulate:
        go_live = pd.Timestamp(args.go_live)
        path = simulate(df, as_of, go_live, args.holdout, args.expedite_cost)
    else:
        path = HERE / "logs" / "events.jsonl"
    if not path.exists():
        print("No events yet. Open the worklist (Stage 4) first, or run with --simulate.")
        return
    events = pd.DataFrame([json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()])
    scored = events[events["event"] == "scored"].drop_duplicates("delivery")
    scored = scored[[c for c in ("delivery", "model", "flag", "holdout", "data_as_of") if c in scored]].astype(
        {"flag": bool, "holdout": bool})
    decisions = events[events["event"] == "decision"] if "action" in events else events.iloc[0:0]
    if args.simulate:
        outcomes = events[events["event"] == "outcome"][["delivery", "late"]].astype({"late": bool})
    else:
        go_live = pd.Timestamp(scored["data_as_of"].min())
        known = df[df["Late"].notna()]
        outcomes = pd.DataFrame({"delivery": known["DeliveryDocument"], "late": known["Late"].astype(bool)})
    rows = scored.merge(outcomes, on="delivery")         # only deliveries whose goods issue is posted
    if rows.empty:
        print(f"{len(scored)} deliveries scored, but none has a goods issue yet. Refresh the data with "
              "sap_data.py after they ship, then run this again.")
        return

    shown = rows[rows["flag"] & ~rows["holdout"]]
    held = rows[rows["flag"] & rows["holdout"]]
    late_shown, late_held = shown["late"].mean(), held["late"].mean()
    avoided = max(0.0, (late_held - late_shown) * len(shown)) if len(held) else float("nan")
    expedites = decisions[decisions["action"] == "expedite"] if len(decisions) else decisions
    spend = float(expedites["cost"].sum()) if len(expedites) else 0.0
    before = df[(df["CreationDate"] < go_live) & (df["CreationDate"] >= go_live - pd.Timedelta(weeks=args.baseline_weeks))
                & df["Late"].notna()]
    created = df.set_index("DeliveryDocument")["CreationDate"].reindex(rows["delivery"])
    weeks = max(1.0, (created.max() - created.min()).days / 7)
    kpis = pd.DataFrame([
        ("Deliveries scored, outcome known", f"{len(rows)}"),
        ("Flagged and shown / flagged in holdout", f"{len(shown)} / {len(held)}"),
        ("Late share, flagged and shown", f"{late_shown:.0%}"),
        ("Late share, flagged in holdout", f"{late_held:.0%}" if len(held) else "n/a"),
        ("Late deliveries avoided (estimate)", f"{avoided:.0f}" if avoided == avoided else "n/a"),
        ("Expedites recorded / spend", f"{len(expedites)} / {spend:,.0f} EUR"),
        ("Spend per late delivery avoided", f"{spend / avoided:,.0f} EUR" if avoided and avoided == avoided else "n/a"),
        ("On-time goods issue, before (baseline)", f"{1 - before['Late'].mean():.1%} ({len(before)} deliveries)"),
        ("On-time goods issue, after go-live", f"{1 - rows['late'].mean():.1%} ({len(rows)} deliveries)"),
        ("Flags shown per week", f"{len(shown) / weeks:.1f}"),
    ], columns=["KPI", "value"])
    label = "SIMULATED (made-up actions and effects)" if args.simulate else "from logs/events.jsonl"
    print(f"\nKPIs {label}\n" + kpis.to_string(index=False, header=False))
    print("\nRead the holdout rows, not before/after: before/after also moves with routes, carriers and season.")
    name = "kpi_report_simulated.md" if args.simulate else "kpi_report.md"
    out = HERE / "reports" / name
    out.parent.mkdir(exist_ok=True)
    out.write_text(f"# Late delivery KPI ({label})\n\n{md_table(kpis)}\n", encoding="utf-8")
    print(f"Saved reports/{name}")


if __name__ == "__main__":
    main()

Step 2: Report on the simulated quarter

  1. Run:

    python projects/late-delivery/kpi.py --simulate

What success looks like:

Wrote 776 simulated deliveries to logs/events_simulated.jsonl (made up, for practice only).

KPIs SIMULATED (made-up actions and effects)
      Deliveries scored, outcome known                    776
Flagged and shown / flagged in holdout                76 / 22
         Late share, flagged and shown                    21%
        Late share, flagged in holdout                    41%
    Late deliveries avoided (estimate)                     15
            Expedites recorded / spend         65 / 3,250 EUR
       Spend per late delivery avoided                215 EUR
On-time goods issue, before (baseline) 86.4% (814 deliveries)
    On-time goods issue, after go-live 86.7% (776 deliveries)
                  Flags shown per week                    6.2

Read the holdout rows, not before/after: before/after also moves with routes, carriers and season.
Saved reports/kpi_report_simulated.md
  1. Read it:
    • The before-and-after line barely moves: 86.4% on time before, 86.7% after. Read alone, it says the worklist did nothing.
    • The holdout line tells the real story. Flagged deliveries that planners saw were late 21% of the time; flagged ones in the holdout, 41%. About 15 late deliveries avoided. The slow route made the whole period worse, which hid the effect in the before-and-after comparison.
    • Spend per late delivery avoided: 215 EUR, against 250 EUR for a late delivery. In this simulation, expediting pays, but only just. If planners expedited every flag, or the save rate were lower, it wouldn't.
    • Only 22 flagged deliveries are in the holdout. One more or one fewer late delivery among them moves their late share by about 5 points. Treat the estimate as a range, and let the holdout run long enough to narrow it.

Step 3: Report on your own log

  1. Run without --simulate:

    python projects/late-delivery/kpi.py
    46 deliveries scored, but none has a goods issue yet. Refresh the data with sap_data.py after they ship, then run this again.

    That is the honest answer for the sample: the open deliveries haven't shipped. With real SAP data, you rerun sap_data.py every week; as goods issues are posted, the same command fills the table from logs/events.jsonl. Use --baseline-weeks to choose how many weeks before go-live count as "before".

What the code does

Part What it does
ASSUMED_SAVE_RATE, ASSUMED_ACTION_RATE The two made-up assumptions of the simulation, in capitals so nobody misses them
simulate Trains the June model, scores July to September at creation, applies the holdout, writes scored, decision and outcome events to logs/events_simulated.jsonl
Outcomes Real mode: posted goods issues from the data. Simulated mode: the outcome events
Groups Flagged and shown vs flagged in holdout; only deliveries whose outcome is known
KPI table Printed and saved as reports/kpi_report.md or reports/kpi_report_simulated.md

If something goes wrong

What you see What it means What to do
No events yet The API hasn't run, so nothing was logged Start the API once (Stage 3), or use --simulate
none has a goods issue yet The scored deliveries are still open Expected with sample data; use --simulate
Late share, flagged in holdout n/a The holdout is empty Start the API with --holdout 0.2 (the default), not 0
Spend per late delivery avoided is n/a No difference, or no expedites, yet Wait for more outcomes; a few weeks of data say little

Human in the loop

Three decisions in this system matter, and the system makes none of them.

  1. Changing a delivery in SAP. Moving a delivery to another truck or route changes SAP data. The worklist only records the planner's decision and adds a line to exports/expedite_requests.csv. The planner makes the change in SAP with their own authorizations.
  2. Deciding for a delivery. Only a planner may record a decision, only for their own shipping points, and only once.
  3. Putting a model live. Training creates a candidate; a second person promotes it.

How it is enforced in code, not in a policy document:

  • No write path to SAP exists. sap_data.py calls SAP only with requests.get, and no other file talks to SAP. There is no SAP user or key with write rights anywhere in the project.
  • decide checks role, shipping point and earlier decisions before it logs anything, and answers 403 or 409 otherwise. Four contract tests prove it.
  • promote refuses the trainer as approver, and a model that isn't cheaper than flagging nothing. Every promotion is logged with who and when.
  • load_model refuses a model file that doesn't match its card, so a model file changed after training is refused. The card sits next to the file, so in production keep the fingerprint where the trainer can't change it, such as in the approval record.

Production gap

What would change for a real customer:

Identity and authorizations

  • Replace the X-User header and the USERS dictionary with real sign-in, such as the identity service on SAP BTP sketched in Unit 6. Roles and shipping points come from the token, and should mirror the planners' authorizations in SAP.
  • Give the approver of promote a real identity too. A command-line name is a demo; a pull request approval or a sign-in to a small admin page is a real control.
  • Use a read-only communication user for the extraction, limited to outbound deliveries.

Data volume and freshness

  • Extract changed deliveries only, with $filter on a date field, and keep each extract with its date so a training run can be repeated. Check the filter syntax your system accepts on the API reference.
  • Check the extraction against SAP's API policy. Version 4.2026a sets per-API limits and preconditions for bulk extraction and replication, and allows large-scale extraction or replication only through SAP-endorsed pathways. Ask your SAP account team whether a weekly delta of delivery history fits, and record the answer.
  • Snapshot features at prediction time. Today's record of an old delivery shows its final state. Planned dates can be rescheduled after creation, and SAP Learning's scheduling lesson describes forward scheduling that confirms new dates. Train on the features the API logged when it scored, not on today's record.

Operations

  • Move the event log, decisions and model registry into a database. Container files vanish on restart.
  • Run monitor.py every Monday and the retraining once a month as scheduled jobs, and send alarms to the model owner. Running AI on SAP BTP in production covers the platform side.
  • Watch the API like any service: latency, errors, and the number of open deliveries scored each day. A sudden drop usually means the extraction failed.

Cost

  • The model is small. One 512 MB app instance serves it; training takes seconds on a few thousand rows and minutes on a few hundred thousand.
  • The real costs are people: the planners' time per flag, a model owner's hour a week, and the process owner's time to agree the costs and the holdout.

Clean core and SAP's own options

Read through released APIs, keep the model side by side, and change nothing in S/4HANA. Expedites go back through the planners' normal apps. Before building, check SAP's own routes, as of October 2026:

  • Embedded: Predicted Delivery Delay. SAP's asset on SAP S/4HANA Predictive Delivery Delays describes machine learning that anticipates delays in order deliveries. NTT DATA, an SAP partner, describes the Predicted Delivery Delay app: for each sales order item it shows the predicted delay in days for delivery creation and processing, with about 130 standard input fields that can be narrowed during training. That is a different prediction moment from this project, which predicts at delivery creation. The description comes from a partner; confirm scope in your release's documentation.
  • ISLM trains, checks and activates embedded scenarios. SAP's overview lists training with customer data, analyzing the quality of the trained model, and an explicit activation step that picks which trained model the application uses. That is the same discipline as train and promote here.
  • SAP AI Core serves your own model. SAP's tutorial deploys a small Python model with a ServingTemplate that defines a KServe inference service, a Docker image with a Python server, and the model file copied to /mnt/models. The model can be swapped without changing the deployment URL; a stopped deployment isn't charged but can't be restarted. SAP's Architecture Center recommends AI Core for custom models and MLOps-heavy work, and lists retraining pipelines, model versions and A/B testing, with monitoring in SAP AI Launchpad.
  • SAP-RPT-1 predicts from example rows without training, and SAP's Architecture Center names it the default for classification and regression, with limits on context size and GPU latency. Tabular AI and SAP's predictive models shows how to test it fairly. The backtest from Stage 6 works for it too: send the rows known on each origin date as context.
  • SAP HANA Cloud PAL and APL score inside the database, which the Architecture Center suggests when latency or data residency rules out the alternatives.
Need Build (this project) SAP
Delay per sales order item inside S/4HANA apps Not needed Predicted Delivery Delay, if your release has it
Your own definition of late, prediction moment or data Good fit Serve the same model on SAP AI Core for SAP-grade operations
A quick first model without a training pipeline Possible, but you maintain it SAP-RPT-1, judged with this project's backtest
Monitoring, retraining and approvals monitor.py, promote and the plan above AI Core versions and pipelines; ISLM's activation step for embedded scenarios

Portfolio checklist

Put the project in your public portfolio repository from Set up for Unit 14. It should contain:

  1. README.md with the problem (late goods issues, reactive expediting), the KPI (on-time goods issue and spend per late delivery avoided, with baseline, target and the holdout), and how to run each stage.
  2. Architecture diagram: the stage overview mermaid diagram, or your own.
  3. Run instructions that work on a fresh clone with --sample and no account, in stage order.
  4. Evaluation report: reports/backtest.md and reports/monitor_m20260630.md, with three sentences on what you learned about drift and retraining.
  5. A one-page design summary: label and prediction moment, features and excluded fields, threshold and costs, the holdout, the approval rules, the retraining plan, and what the Production gap would change.
  6. A 3-minute demo script: the one in the foundational layer works; rehearse it with the worklist page.

Leave out data/, models/, logs/, exports/ and .env. Keep the simulated KPI report's name, kpi_report_simulated.md, so nobody mistakes it for results.

Save your work:

git add .gitignore projects/late-delivery
git status

Check that git status lists no data/, models/, logs/ or exports/ files, then:

git commit -m "Project: late delivery predictor"

Exercise: add the planners' flag limit to the promotion gate

Before you start: complete Set up your computer for this course, Set up for Unit 2 and Set up for Unit 6, and Stages 1 to 6 above.

The backtest showed retraining can double the flags per week. A model that floods planners fails even if its cost is lower. You will make the promotion gate refuse a model whose test produced more flags per week than the planners agreed to handle, then write a retraining record. The same approval gate returns in the later projects of this unit.

  1. Open projects/late-delivery/late_model.py and find def promote(version: str, approved_by: str) -> None:. Change it to:

    def promote(version: str, approved_by: str, max_flags: float = 15.0) -> None:
  2. In the same function, find the line ending not less than flagging nothing ({test['cost_flag_nothing']:,.0f} EUR)."). Below it, add these lines, with the if at the same indentation as the if above it:

        if test["flags_per_week"] > max_flags:
            sys.exit(f"Refused: {version} raised {test['flags_per_week']} flags a week in its test; "
                     f"the planners agreed to at most {max_flags}.")
  3. In main, find the line that adds --approved-by. Below it, add:

        p.add_argument("--max-flags-per-week", type=float, default=15.0, help="the planners' agreed limit")
  4. Still in main, change promote(args.version, args.approved_by) to:

            promote(args.version, args.approved_by, args.max_flags_per_week)
  5. Save, then try to promote the June model with a limit of 10:

    python projects/late-delivery/late_model.py promote m20260630 --approved-by sam --max-flags-per-week 10
    Refused: m20260630 raised 10.3 flags a week in its test; the planners agreed to at most 10.0.
  6. Promote the current model with the same limit. It passes, because its test raised 9.0 flags a week:

    python projects/late-delivery/late_model.py promote m20261009 --approved-by sam --max-flags-per-week 10
  7. Run the contract tests again to check nothing else broke: python projects/late-delivery/test_app.py.

  8. Create projects/late-delivery/reports/retraining_record.md with five lines: the monitoring alarms that triggered retraining (from reports/monitor_m20260630.md), the candidate's test recall, flags per week and cost (from models/m20261009.json), the backtest result, the flag limit you used, and who approved.

Done when: promoting m20260630 with --max-flags-per-week 10 is refused with the message above; m20261009 is live (late_model.py list shows LIVE next to it); the contract tests pass 15 of 15; and reports/retraining_record.md names the alarms, the candidate's numbers, the limit and the approver.

Check yourself

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

1Why does train_model refit the chosen model on every known outcome after the test?

Answer: A. The test judges the recipe on a period the model never saw. The live model then learns from all outcomes, including the latest, which in the sample hold the slow route. Stage 6 checks the live model's real performance.

2load_model refuses a file whose SHA-256 fingerprint doesn't match its card. Which risk does that control?

Answer: C. scikit-learn's guide warns that pickle and joblib files can execute arbitrary code when loaded. Checking the fingerprint means only the exact file that was trained and approved is ever loaded.

3A system posts a delivery to /api/score including ActualGoodsMovementDate. What happens, and why?

Answer: D. extra="forbid" turns the leakage rule into a contract. A field filled after goods issue can't reach the model, and a contract test proves it stays that way.

4Omar tries to record a decision for a delivery at shipping point 1010. What does the API answer?

Answer: A. decide checks the role, then that the delivery is open, then the user's shipping points, then earlier decisions. It mirrors the planner's area of responsibility in SAP.

5In the backtest, why are July's numbers identical for the frozen and the retrained model?

Answer: A. The retrained model for each month is trained on the last day of the month before. For July that is 30 June, the frozen model's date, so the first month is the same model by design.

6Monitoring shows 8% of recent deliveries on a route the model has never seen. Why is that an alarm and not just a note?

Answer: A. Ignoring unknown codes keeps scoring running, but the model then treats those deliveries like average ones. Only monitoring notices, and the fix is retraining on outcomes that include the new route.

7Monitoring the live model trained today says "Nothing to judge yet". What should you do?

Answer: D. Monitoring must use deliveries created after training whose outcomes are known. Using younger deliveries biases the window towards on-time ones, and using training data says nothing about drift.

8The simulated KPI shows 86.4% on time before and 86.7% after go-live, but 21% vs 41% late among flagged deliveries shown vs held out. What do you tell the process owner?

Answer: B. Before and after mixes the worklist's effect with everything else that changed, including the slow route. The holdout compares like with like. And because the simulation's effect is assumed, only real logs can show the real size.

9A newly retrained model is cheaper than the live one on its test, but it raises 18 flags a week and planners agreed to 12. What would you do?

Answer: B. Flag volume is a guardrail in the metric contract. A model that floods planners gets ignored, whatever its cost on paper. The exercise turns that limit into a refusal in promote.

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.

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