Skip to content

Processes and shelf life

Physical flows such as expiry, inspections, and returns. Guide: Shelf life and inventory processes.

ShelfLife

ShelfLife(
    shelf_life_days: int,
    opening_lots: DataFrame,
    opening_expiry_handling: str = "reject",
    *,
    sku_column: str = "unique_id",
)

Bases: InventoryProcess

FIFO shelf life as an InventoryProcess.

Stock is tracked as dated lots in a FIFOLotLedger. Each period:

1. before demand, lots aged ``shelf_life_days`` or more expire; the
   units leave on_hand before receipts, the order decision and demand,
   and are recorded in ``expired_units``;
2. every receipt (pipeline arrivals, then zero-lead-time receipts)
   becomes a lot dated with the current period date;
3. after demand, fulfilled demand and cleared backlog consume the
   oldest lots first, and the ledger is checked against on_hand.

Other processes' flows and callback adjustments are mirrored: removals consume the oldest lots, and additions need an explicit, unexpired received_date (never invented).

Parameters:

Name Type Description Default
shelf_life_days int

calendar days a lot remains usable (integer >= 1).

required
opening_lots DataFrame

DataFrame with unique_id, received_date and quantity; lot quantities must add up to opening on_hand for every SKU.

required
opening_expiry_handling str

"reject" (default) fails if an opening lot is already expired at the opening date; "write_off" writes that stock off before the run (recorded in the manifest, not as a period flow).

'reject'
sku_column str

SKU column of opening_lots (default "unique_id").

'unique_id'

ledger holds the lots of the latest run (the last branch of a comparison).

InventoryProcess

Base class for a physical process that changes on-hand stock.

Subclasses set name (unique within a run) and flows (a tuple of Flow), then override only the hooks they need. The engine calls the exact instances passed to run and resets them before every run, so run-local state (a lot ledger, a random generator) belongs in reset.

Flow hooks return ProcessFlows or None: before_demand(context) start of period, before receipts and ordering after_demand(context) after demand is served, before callbacks

Observation hooks return None: reset(context) before the first period of each run on_receipt(context) stock just received (context.received) check(context) assert an internal mirror of on-hand stock; runs after demand and after each accepted callback adjustment batch

Composition hooks, for processes that mirror stock (for example lots): validate_stock_change(change, context) raise to reject a change on_stock_change(change, context) mirror it; may return evidence text for the audit

Processes run in list order; each sees the stock left by the ones before it. A process is told about every other process's flows and every callback adjustment, but not about its own flows.

reset

reset(context: ProcessContext) -> None

Reset run-local state before a simulation starts.

before_demand

before_demand(
    context: ProcessContext,
) -> Optional[ProcessFlows]

Report flows at the start of the period.

on_receipt

on_receipt(context: ProcessContext) -> None

Observe units received this period (context.received).

after_demand

after_demand(
    context: ProcessContext,
) -> Optional[ProcessFlows]

Report flows after demand is served.

validate_stock_change

validate_stock_change(
    change: StockChange, context: ProcessContext
) -> None

Raise to reject an on-hand change before anything is applied.

on_stock_change

on_stock_change(
    change: StockChange, context: ProcessContext
) -> Optional[str]

Mirror an on-hand change made by another process or a callback.

check

check(context: ProcessContext) -> None

Assert that internal state still agrees with context.on_hand.

get_config

get_config() -> dict

Return JSON-serializable configuration for the run manifest.

Flow dataclass

Flow(name: str, direction: str, category: str = 'general')

One named, signed physical flow that a process may report.

direction is "inflow" (adds on-hand units) or "outflow" (removes on-hand units). category="expiry" marks an outflow as expiry: it is recorded in the canonical expired_units event table column and priced by waste_cost. Every other flow is "general" and is recorded in process_inflow_units / process_outflow_units.

ProcessFlows

ProcessFlows(
    quantities: Mapping[str, object],
    received_dates: Optional[Mapping[str, object]] = None,
)

Quantities a process reports for its declared flows at one phase.

quantities maps a declared flow name to nonnegative per-SKU units, given as a mapping or a pd.Series indexed by SKU. Omitted SKUs and zero values mean no flow. The flow's direction comes from its Flow declaration, never from the sign of the quantity.

received_dates optionally gives inflows a lot date, per flow, as one timestamp or per SKU (a mapping or a pd.Series indexed by SKU). Processes that track lot ages (such as ShelfLife) require it for every inflow they observe.

quantities property

quantities: dict

A copy of the per-flow quantities.

received_dates property

received_dates: dict

A copy of the per-flow lot dates.

ProcessContext dataclass

ProcessContext(
    phase: str,
    period: int,
    demand_period: Optional[int],
    date: Timestamp,
    run_window: str,
    unique_id: Index,
    on_hand: Series,
    backorders: Series,
    on_order: Series,
    received: Optional[Series] = None,
    demand: Optional[Series] = None,
    fulfilled: Optional[Series] = None,
    backorders_fulfilled: Optional[Series] = None,
)

Read-only per-SKU view of the run at one process phase.

Every quantity is a float pd.Series indexed by SKU identifier, in the state's SKU order. received, demand, fulfilled and backorders_fulfilled are None where the phase has no such value:

  • before_demand: none of them (the period has not received or sold);
  • on_receipt: received holds the units being received now;
  • after_demand and callback_adjustment: all four hold this period's values.

period and date identify the demand period being processed (the event-table coordinates); demand_period is its zero-based index. The context is a copy: changing it never changes engine state.

StockChange dataclass

StockChange(
    unique_id: object,
    quantity: float,
    received_date: Timestamp,
    origin: str,
    source: str,
)

One on-hand change made by something other than the notified process.

quantity is signed: positive units were added, negative removed. origin is "process" (another process's flow; source is "<process>.<flow>") or "callback" (an accepted InventoryAdjustmentResult row; source is the callback class). received_date is the lot date given for an addition, else NaT.

FIFOLotLedger

FIFOLotLedger(shelf_life_days: int)

Dated-lot ledger for multi-SKU on-hand stock with FIFO consumption.

The ledger mirrors on_hand: every unit on hand belongs to exactly one lot with a receipt date. Consumption removes from the oldest lot first; expiry removes lots whose age reached shelf_life_days.

seed_from_lots

seed_from_lots(
    inventory: InventoryStateDataFrame,
    opening_lots: DataFrame,
) -> None

Seed explicit opening lots and assert they equal opening on-hand.

receive

receive(unique_id, qty: float, date: Timestamp) -> None

Add a lot of qty units received on date (zero is skipped with a warning).

expire

expire(current_date: Timestamp) -> Dict[object, float]

Remove lots aged >= shelf_life_days; return expired qty per SKU.

consume

consume(unique_id, qty: float) -> list[dict]

Consume qty from the oldest lots first.

balances

balances() -> Series

Total ledger quantity per SKU.