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.

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 < 12are 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 — 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¶
- Ingest. ERP rows arrive via
POST /api/supply/firm-orders/import(bulk) orPOST /api/supply/firm-orders(single). PG writes one row per order inmaster_supply_orders_firmwithrelease_dt,arrival_dt, anderp_status. - CH mirror. The CH
MASTER_supply_orders_firmtable uses the PostgreSQL engine — it reads PG live on every query. No replication lag, no separate write path. - Supply run.
supply_runner.py:- Loads firm rows via
_load_firm_supply_orders(conn, schema, today). - Buckets
arrival_dt → week index 0..51and buildsscheduled_receipts[52]per SKU. - Computes
latest_firm_by_type[7]fromrelease_dt(one slot perOrderType). - Loads
min_lt_by_type[7]frommaster.routejoined tomaster.route_type. - Attaches the two arrays to every
SkuPlandict sent into the Rust engine.
- Loads firm rows via
- Rust engine.
EngineConfig.firm_orders_block_horizonandrespect_lead_timeare forwarded intoSourcingContext. Pass-2 reads the per-SKU arrays viaOrderType::idx()and skips routes whose target week violates either bound. - Allocation.
allocation_runner.load_supplies()reads recommendations fromPIPE_supply_ordersAND firm rows fromMASTER_supply_orders_firm. Firm rows getsupply_id = "FIRM_<id>"andreliability_score = 1.0. Allocation results carryis_firm = 1when the picked supply came from the firm table. - UI. The supply grids fetch
GET /api/supply/orders?include_firm=true. Each row carriesis_firmanderp_status. Firm rows are rendered greyed with the 🔒 icon. A "Show firm orders" toggle in the toolbar flipsinclude_firmtofalseto 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.

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.

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¶
6.1 Bulk import (recommended for ERP integration)¶
[
{
"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:
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¶
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:
- Reliability score — firm rows enter with
reliability_score = 1.0, recommendations with0.85. The allocation scoring engine prefers higher-reliability supply when both can satisfy the same demand. is_firmflag —PIPE_allocation_results.is_firm = 1when the allocated supply came fromMASTER_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.

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)¶
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
plannedorder into the firm table is a follow-up — todayinteract_order_approvalsrecords the approval but does not auto-promote. - Expedite / push-pass blocking. The
firm_orders_block_horizonflag governs the main sourcing pass.BUY_EXPEDITEandTRANSFER_PREPOSorders are produced by a separate post-processor and currently do not respect the flag.
See also¶
- Supply Planning — core concepts
- Advanced Supply Planning — capacity, calendars, returns, pegging
- Allocation — how supply is allocated to demand
- Configuration → Parameter System
- Developer → Database Schema