Skip to content

Firm Supply Orders

What you'll learn

  • What firm supply orders are and why they matter
  • How they differ from engine recommendations
  • Two new scenario parameters: Firm Orders Block Horizon and Respect Lead Time
  • End-to-end data flow from ERP → PG → CH → supply engine → allocation → UI
  • How to manage them in the UI and via the API
  • Operational considerations (deploy, backfill, ERP sync)

1. What is a firm supply order?

A firm supply order is a real, ERP-originated purchase order, work order, or transfer that has already been committed. It is an input to the supply planning engine — never an output.

The supply engine treats firm orders as guaranteed inbound supply and fills the gaps around them with planned recommendations. It must:

  • treat firm quantities as guaranteed (no reliability discount in allocation);
  • never delete or modify them on re-run;
  • optionally avoid creating competing recommendations of the same type before the firm order is due (new flags below).

Firm orders are stored per-tenant in scenario.master_supply_orders_firm (PG) and mirrored read-only into ClickHouse as MASTER_supply_orders_firm via the PostgreSQL engine connector.

ERP lifecycle (erp_status)

Status Meaning Engine reads? Allocation reads?
firm Approved by the planner / committed by the ERP, but not yet released.
released Released to the supplier (PO sent, work order opened).
in_transit Goods are physically moving — booked into receiving.
received Goods have arrived; the firm order is closed.

Setting received_qty allows partial receipts: the engine subtracts received_qty from qty when computing the remaining scheduled receipt.


2. Firm vs engine recommendations — at a glance

Aspect Firm supply order Engine recommendation
Storage master_supply_orders_firm (PG) pipe_supply_orders (CH, partitioned by pipeline_id)
Produced by ERP / manual upload / POST /supply/firm-orders Supply engine (Rust, called by supply_runner.py)
Time anchor Real datetime (release_dt, arrival_dt) Week index 0–51
Mutable? Yes — via API only Yes — engine replaces them on every supply run
Status values firm, released, in_transit, received planned, constrained, delayed, expedited
Allocation reliability 1.0 (full confidence) 0.85 (engine confidence)
Allocation result tag is_firm = 1 is_firm = 0
UI rendering Greyed, italic, 🔒 lock icon, read-only Normal text, action menu available

3. The two new scenario parameters

Both parameters live in the supply scenario's parameter set and can be edited from Pipelines → Supply Scenarios → (open scenario) → Configuration.

Supply Scenario edit modal — new BLOCK PER-TYPE and RESP. LT columns next to FIRM WKS

The two new columns visible in the parameter set grid are:

  • BLOCK PER-TYPE (firm_orders_block_horizon)
  • RESP. LT (respect_lead_time)

They sit next to the existing FIRM WKS global frozen-horizon control. All three can compose.

3.1 firm_orders_block_horizon

Property Value
Default false
Type boolean
Scope per-(item, site, order_type)
Compares against release_dt of the latest firm order of the same type

When ON, for every (item, site, order_type) triple the engine cannot create a new recommendation before the latest firm order's release week of the same type.

Worked example. A firm BUY order has release_dt = 2026-09-01 for (item_id=2, site_id=100). The supply run anchors today = 2026-06-06, so the firm's release week index is W12. With the flag ON:

  • New BUY recommendations with release_week < 12 are blocked for that SKU.
  • REPAIR recommendations are still allowed in W0..W11 — the rule is per-order-type.
  • TRANSFER recommendations are still allowed too.

If multiple firm BUY orders exist for the same SKU, the latest release week wins (the planner has the most recent commitment as the binding constraint).

3.2 respect_lead_time

Property Value
Default false
Type boolean
Scope per-(item, site, order_type)
Compares against MIN lead-time across all routes of that order_type for that SKU

When ON, for every (item, site, order_type), the engine cannot create a new recommendation within the minimum lead-time window for that order_type.

Worked example. A REPAIR route has lead_time = 4w. With the flag ON, no REPAIR recommendation can be released in weeks 0–3 for that SKU — the supplier physically cannot deliver any sooner than 4 weeks out.

When multiple routes of the same order_type exist, the minimum lead-time across them is used (most permissive — the engine assumes the planner can use the fastest available route).

3.3 How the three frozen-horizon controls compose

flowchart LR
    A[Candidate route] --> B{firm_weeks<br/>global frozen?}
    B -- t < firm_weeks --> X[BLOCK]
    B -- ok --> C{firm_orders_block_horizon<br/>flag ON?}
    C -- t < latest_firm[type] --> X
    C -- ok --> D{respect_lead_time<br/>flag ON?}
    D -- t < min_lt[type] --> X
    D -- ok --> E[ALLOW &mdash; route enters sourcing]

A candidate enters sourcing only if all three checks pass. Each check operates on a different time bound:

Check Bound Granularity
firm_weeks Global N weeks frozen All order types
firm_orders_block_horizon Latest firm of same type Per order_type
respect_lead_time Min lead-time of same type Per order_type

4. End-to-end data flow

flowchart TD
    subgraph ERP["ERP / Excel"]
        E1[SAP / Oracle / manual upload]
    end

    subgraph PG["PostgreSQL (tenant scenario schema)"]
        T1["master_supply_orders_firm<br/>📋 datetime-anchored<br/>+ erp_status lifecycle"]
    end

    subgraph CH["ClickHouse (tenant DB)"]
        M1["MASTER_supply_orders_firm<br/>🔗 read-only PG-engine mirror"]
        P1["PIPE_supply_orders<br/>📊 engine output<br/>(planned/constrained/delayed/expedited)"]
        A1["PIPE_allocation_results<br/>+ is_firm flag"]
    end

    subgraph PY["Python runtime"]
        R1["supply_runner.py<br/>_load_firm_supply_orders<br/>_load_min_lt_by_type"]
        R2["allocation_runner.py<br/>load_supplies UNION"]
    end

    subgraph RUST["Rust supply_engine"]
        S1["SkuPlan<br/>+ latest_firm_by_type[7]<br/>+ min_lt_by_type[7]"]
        S2["sourcing.rs Pass-2<br/>per-type skip checks"]
    end

    subgraph UI["React UI"]
        U1["Supply Orders Grid<br/>🔒 firm rows greyed"]
        U2["Detail Supply tab<br/>🔒 firm rows greyed"]
        U3["Allocation Overview<br/>is_firm breakdown"]
    end

    E1 -->|"POST /supply/firm-orders/import"| T1
    T1 --> M1
    T1 -->|"buckets dt → week"| R1
    R1 -->|"per-SKU arrays"| S1
    S1 --> S2
    S2 --> P1
    M1 -->|"UNION with PIPE_supply_orders"| R2
    P1 --> R2
    R2 --> A1
    T1 -->|"GET /supply/orders?include_firm=true"| U1
    T1 --> U2
    A1 --> U3

Step-by-step

  1. Ingest. ERP rows arrive via POST /api/supply/firm-orders/import (bulk) or POST /api/supply/firm-orders (single). PG writes one row per order in master_supply_orders_firm with release_dt, arrival_dt, and erp_status.
  2. CH mirror. The CH MASTER_supply_orders_firm table uses the PostgreSQL engine — it reads PG live on every query. No replication lag, no separate write path.
  3. Supply run. supply_runner.py:
    • Loads firm rows via _load_firm_supply_orders(conn, schema, today).
    • Buckets arrival_dt → week index 0..51 and builds scheduled_receipts[52] per SKU.
    • Computes latest_firm_by_type[7] from release_dt (one slot per OrderType).
    • Loads min_lt_by_type[7] from master.route joined to master.route_type.
    • Attaches the two arrays to every SkuPlan dict sent into the Rust engine.
  4. Rust engine. EngineConfig.firm_orders_block_horizon and respect_lead_time are forwarded into SourcingContext. Pass-2 reads the per-SKU arrays via OrderType::idx() and skips routes whose target week violates either bound.
  5. Allocation. allocation_runner.load_supplies() reads recommendations from PIPE_supply_orders AND firm rows from MASTER_supply_orders_firm. Firm rows get supply_id = "FIRM_<id>" and reliability_score = 1.0. Allocation results carry is_firm = 1 when the picked supply came from the firm table.
  6. UI. The supply grids fetch GET /api/supply/orders?include_firm=true. Each row carries is_firm and erp_status. Firm rows are rendered greyed with the 🔒 icon. A "Show firm orders" toggle in the toolbar flips include_firm to false to focus on recommendations only.

5. Viewing firm orders in the UI

5.1 Supply dashboard

The Order Type Breakdown by Week chart aggregates both firm and recommendation volumes. When firm orders exist they appear stacked alongside engine BUY, REPAIR, TRANSFER, etc.

Supply dashboard — Order Type Breakdown chart

5.2 SKU detail (Time Series Viewer → Supply tab)

When a planner drills into a single SKU, the Supply tab shows the Inventory Projection chart and the Orders panel. Firm rows appear intertwined with engine recommendations in the order list; the inventory projection already accounts for firm receipts as scheduled_receipts.

Time Series Viewer — Supply tab inventory projection for a SKU with firm receipts

Visual markers on firm rows:

  • 🔒 Lock icon before the item name.
  • Grey background and italic text.
  • Tooltip on hover showing Firm — from ERP (status: <erp_status>). Read-only.
  • No edit / approve action menu — only "View detail" remains.

5.3 "Show firm orders" toggle

A toolbar checkbox lets the planner hide firm rows to focus solely on recommendations that need attention:

Toggle state API call UI
Show firm orders ✔ (default) include_firm=true Firm rows unioned + greyed
Show firm orders include_firm=false Only engine recommendations shown

6. Managing firm orders — API reference

POST /api/supply/firm-orders/import
Content-Type: application/json
Authorization: Bearer <token>
[
  {
    "external_id":   "PO-2026-9241",
    "item_id":       2,
    "site_id":       100,
    "order_type":    "BUY",
    "qty":           500,
    "release_dt":    "2026-09-01T00:00:00Z",
    "arrival_dt":    "2026-10-15T00:00:00Z",
    "erp_status":    "firm",
    "source_system": "erp_sap"
  }
]

Idempotent: the endpoint uses ON CONFLICT (external_id, source_system) DO UPDATE, so re-imports from the same ERP system overwrite the previous values for the same key.

Response:

{ "inserted": 12345, "updated": 287, "errors": [...] }

6.2 Single-row CRUD

Method Path Purpose
GET /api/supply/firm-orders List with filters (item_id, site_id, order_type, erp_status, limit, offset).
GET /api/supply/firm-orders/{id} Single row.
POST /api/supply/firm-orders Create one.
PUT /api/supply/firm-orders/{id} Update mutable fields (qty, release_dt, arrival_dt, erp_status, received_qty, expiry_date, attributes).
DELETE /api/supply/firm-orders/{id} Delete one.

6.3 Reading firm + engine orders together

GET /api/supply/orders?pipeline_id=4&include_firm=true&item_id=2&site_id=100

Returns {total, offset, limit, orders: [...]} where each row carries is_firm: 0|1 and erp_status so the UI can render the row appropriately.

Alternatively, firm_only=true returns ONLY firm rows — useful for a dedicated planner view.

6.4 Validation rules

Rule Detail
order_type{BUY, BUILD, REPAIR, TRANSFER, REPLENISH, RETURN, BALANCING} Mirrors the supply engine's OrderType enum.
erp_status{firm, released, in_transit, received} Single column for the full lifecycle.
qty > 0 Mandatory.
(external_id, source_system) unique Lets the same external ID coexist across ERP systems.

7. Allocation impact

Firm orders flow into the allocation engine through the same path as engine recommendations, with two differences:

  1. Reliability score — firm rows enter with reliability_score = 1.0, recommendations with 0.85. The allocation scoring engine prefers higher-reliability supply when both can satisfy the same demand.
  2. is_firm flagPIPE_allocation_results.is_firm = 1 when the allocated supply came from MASTER_supply_orders_firm. This lets the allocation overview render firm-vs-recommendation breakdowns.

Supply Path Modal

In the Allocation Detail tab of the Time Series Viewer, clicking either a demand bar in the time-phased allocation timeline OR a row in the Demand Allocation table opens a centered Supply Path overlay. Firm supplies in the chain carry a 🔒 FIRM badge and a blue accent so the planner can see at a glance whether the demand is covered by guaranteed (firm) supply or by engine recommendations.

Supply Path Modal with a transfer leg

See Allocation → Supply Path Modal for the full visual contract.

Process log

The allocation engine is auto-triggered after the supply step and is logged as a sub-process of supply in the process log:

└─ supply           success   28 255 orders, 23 228 exceptions
    └─ allocation   success   55 580 fully filled, 7.6% fill rate

Manual standalone allocation runs (triggered from POST /api/pipeline/run/allocation) remain top-level entries — they have no parent.


8. Database schema

8.1 scenario.master_supply_orders_firm (PostgreSQL)

CREATE TABLE master_supply_orders_firm (
    id              BIGSERIAL PRIMARY KEY,
    external_id     TEXT,                                   -- ERP PO/work-order ID
    item_id         BIGINT NOT NULL,
    site_id         BIGINT NOT NULL,
    source_site_id  BIGINT,                                 -- TRANSFER orders only
    order_type      VARCHAR(20) NOT NULL
                    CHECK (order_type IN
                      ('BUY','BUILD','REPAIR','TRANSFER','REPLENISH','RETURN','BALANCING')),
    qty             DOUBLE PRECISION NOT NULL,
    release_dt      TIMESTAMPTZ NOT NULL,                    -- when order is placed
    arrival_dt      TIMESTAMPTZ NOT NULL,                    -- when stock arrives
    route_id        BIGINT,
    unit_cost       DOUBLE PRECISION,
    order_cost      DOUBLE PRECISION,
    total_cost      DOUBLE PRECISION,
    erp_status      VARCHAR(20) NOT NULL DEFAULT 'firm'
                    CHECK (erp_status IN
                      ('firm','released','in_transit','received')),
    received_qty    DOUBLE PRECISION DEFAULT 0,              -- partial receipt tracking
    expiry_date     DATE,
    attributes      JSONB,
    source_system   TEXT,                                    -- 'erp_sap' / 'manual_upload' / ...
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (external_id, source_system)
);

8.2 MASTER_supply_orders_firm (ClickHouse)

CREATE TABLE MASTER_supply_orders_firm (...)
ENGINE = PostgreSQL(
    'host.docker.internal:5432',
    '<tenant_db>', 'master_supply_orders_firm',
    'postgres', '<password>', 'scenario'
);

The CH mirror is read-only — every query reads PG live. No CDC, no replication lag, no consistency issues.

8.3 PIPE_allocation_results.is_firm (ClickHouse)

ALTER TABLE PIPE_allocation_results
ADD COLUMN IF NOT EXISTS is_firm UInt8 DEFAULT 0;

9. Deployment notes

Step Action Notes
1 Apply PG DDL master_supply_orders_firm table + indexes + one-time migration block (supply_schema.sql).
2 Apply CH DDL MASTER_supply_orders_firm PG-engine mirror + is_firm column on PIPE_allocation_results.
3 Migrate legacy rows The DDL migration block lifts status IN ('firm','released','in_transit') rows from pipe_supply_orders into the new table. Skipped if master_supply_orders_firm already has any rows.
4 Rebuild Rust engine maturin build --release + pip install --force-reinstall. Required because SkuPlan got two new fields.
5 Restart API supply_runner.py config defaults include the two new flags.
6 Enable flags per scenario Default is OFF. Edit param_overrides on config_supply_scenario rows to enable per scenario.

10. Out of scope / future work

  • Multi-granularity engine (hourly / daily / weekly). The firm table stores full datetimes so the data model is granularity-ready; the Rust engine itself still buckets to weekly bins in this release. Multi-granularity is a separate epic.
  • Approval-to-firm workflow. Promoting an approved planned order into the firm table is a follow-up — today interact_order_approvals records the approval but does not auto-promote.
  • Expedite / push-pass blocking. The firm_orders_block_horizon flag governs the main sourcing pass. BUY_EXPEDITE and TRANSFER_PREPOS orders are produced by a separate post-processor and currently do not respect the flag.

See also