Skip to content

On-Hand Stock Types

Status: Implemented. Source: files/supply_runner.py (engine split), files/api/main.py (Overview KPIs + network map), files/allocation_runner.py (allocation supply pool).

Every on-hand row in master.on_hand carries a stock type (master.on_hand_type). The type's planning_type column is the planning-engine classification — the single field that decides whether a unit of stock is serviceable ("good") or unserviceable ("bad") everywhere in Mirabelle.


The one classification rule

All consumers use one definition:

Class Rule Meaning
Bad (unserviceable) planning_type = 'bad' In repair, scrap, otherwise unsellable. Feeds the repair pool; excluded from usable stock.
Good (serviceable) planning_type IS DISTINCT FROM 'bad' Everything else — including AVAILABLE, good, new, and rows with no type. Usable for demand, transfers, allocation.

The rule is case-sensitive SQL text matching, so the value must be written exactly bad (lowercase). BAD, Bad, or bad (trailing space) will NOT match — the stock is then silently treated as serviceable by every consumer.

Canonical planning_type values

Value Class Typical use
bad Bad Unserviceable stock: in repair / wash, scrap, quarantine-to-repair
new Good (own bucket) Newly purchased stock — allocation sequencing treats it after existing good stock
good Good Generic serviceable stock
AVAILABLE Good Available / unrestricted stock (the value used by the GPS tenants' UNRESTRICTED type)
(empty / NULL) Good Rows with no type are serviceable

Any other value is allowed and counts as good — define as many named types as your business needs (e.g. UNRESTRICTED, IN_REPAIR, SCRAP, QUARANTINE), as long as unserviceable types carry planning_type = 'bad'.


Where good / bad shows up

Surface What it shows
Dashboard Overview KPI cards ("On-hand good / bad") Good = serviceable qty (not bad); Bad = bad qty. Values use unit_cost → item_location.cost_price fallback.
Dashboard network map tooltips Same split per site.
Supply engine (files/supply_runner.py) initial_inventory = serviceable; initial_oh_bad = bad pool → REPAIR orders are sized from it; bad stock never ships on good-flow routes.
Allocation engine (files/allocation_runner.py) On-hand serviceable rows enter the supply pool; bad rows never do.
Zone balancing (files/zone_balancing.py) Excess is computed on serviceable stock only.

Case sensitivity incident (Aug 2026, GPS 2)

The GPS 2 tenant was seeded with planning_type = 'BAD' (uppercase) on its IN_REPAIR / SCRAP types. Result: the Overview showed no bad stock, and — worse — the supply engine counted ~69,600 in-repair/scrap units as serviceable inventory. The seed CSVs now use lowercase bad, and the tenant data was repaired. If a re-import ever reintroduces uppercase values, every good/bad split silently degrades again.


Seeding stock types

Stock types are seeded through the import schema (see Data Onboarding §10): stage import.on_hand_type, then run the import ETL step on_hand_type (upsert by xuid).

on_hand_type.csv:

Column Required Description
xuid Yes Your ERP type code (referenced by on_hand.csv's on_hand_type_xuid)
name Yes Human-readable type name
description No Free text
planning_type No Engine classification — lowercase bad for unserviceable; anything else (e.g. AVAILABLE) = serviceable
allocation_sequence No Priority order for allocation (1 = highest)

on_hand.csv then references the type per stock row via on_hand_type_xuid.


Tenant example — GPS pooled crates (SAP storage locations)

The GPS tenants map SAP STORAGE_LOC codes to stock types. 1AVL ("Available") is good stock — as are direct-use, empty, and full crates:

SAP STORAGE_LOC Meaning Stock type planning_type Class
1AVL Available UNRESTRICTED AVAILABLE Good
2DIR Direct-use UNRESTRICTED AVAILABLE Good
7EMP Empty UNRESTRICTED AVAILABLE Good
8FUL Full UNRESTRICTED AVAILABLE Good
3REP In repair IN_REPAIR bad Bad
REPR Repair plant IN_REPAIR bad Bad
5SCR Scrap SCRAP bad Bad

Bad crates at a depot sit in its bad pool: the supply engine sizes RETURN and REPAIR orders from it — bad stock flows back through the reverse-logistics chain and re-enters the good pool at the repair yield.


Verifying a tenant

-- Type vocabulary + row/qty usage
SELECT t.id, t.xuid, t.planning_type, count(oh.id), COALESCE(SUM(oh.qty), 0)
FROM master.on_hand_type t
LEFT JOIN master.on_hand oh ON oh.type_id = t.id
GROUP BY 1, 2, 3 ORDER BY 1;

-- The Overview KPI split
SELECT
    SUM(CASE WHEN oht.planning_type IS DISTINCT FROM 'bad' THEN oh.qty ELSE 0 END) AS good_qty,
    SUM(CASE WHEN oht.planning_type = 'bad' THEN oh.qty ELSE 0 END) AS bad_qty
FROM master.on_hand oh
LEFT JOIN master.on_hand_type oht ON oht.id = oh.type_id;

Any unserviceable type whose planning_type is not exactly bad is a data bug — fix the type row (and the staging CSV) before trusting good/bad numbers.