Functional Weave
Code in TypeScript

inventory.expiry-fefo

First-expiry-first-out pick list for a quantity across batches, skipping expired and short-dated stock.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 15 tests, run in TypeScript, Python and Rust.

What it does

First expiry, first out: fill a request from the batches that expire soonest, so perishable, dated or shelf-life stock is used before it goes out of date.

**What counts as in date.** A batch is usable when it has at least `minShelfLifeDays` days left on the pick date: `daysBetween(asAt, expiry) >= minShelfLifeDays`. With 0, stock expiring on the pick date itself is still usable (a use-by date is the last day of use); pass 1 to exclude it, or a customer's minimum remaining shelf life (say 10 days) to keep short-dated stock back. Batches that fail are never picked and are listed in `skipped`, earliest expiry first, so they can be quarantined or written off.

For example

  • fefoPick(batches ×5, 12, 2026-09-23, 0) → lines ×2, picked 12, shortfall 0, skipped B3 earliest expiry first, expired B3 skipped, B2 and B4 share a date and go in the order given
  • fefoPick(batches ×5, 12, 2026-09-23, 10) → lines ×2, picked 12, shortfall 0, skipped B3, B2, B4 a 10-day minimum shelf life skips B2 and B4 (7 days left) as well
  • fefoPick(batches ×5, 50, 2026-09-23, 0) → lines ×4, picked 38, shortfall 12, skipped B3 not enough in date: everything usable is picked and the rest is a shortfall

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

export function fefoPick(batches: readonly StockBatch[], quantity: number, asAt: string, minShelfLifeDays: number): FefoPick
batchesStockBatch[]stock on hand by batch, in any order; equal expiry dates are picked in this order
quantityintunits wanted
asAtdatethe pick date
minShelfLifeDaysintdays of life a unit must have left on asAt: 0 allows stock expiring that day
returnsFefoPickwhat to pick, oldest expiry first

The types it declares, generated into your project

/** Units of one batch or lot on hand. */
export interface StockBatch {
  readonly batch: string;
  /** the last day the stock may be used or sold */
  readonly expiry: string;
  /** units on hand, not negative */
  readonly quantity: number;
}

/** Units to take from one batch. */
export interface PickLine {
  readonly batch: string;
  readonly expiry: string;
  readonly quantity: number;
}

/** The pick list, and what could not be picked. */
export interface FefoPick {
  /** in pick order, earliest expiry first */
  readonly lines: readonly PickLine[];
  /** units on the list */
  readonly picked: number;
  /** units wanted but not available in date; 0 when the request is met */
  readonly shortfall: number;
  /** batches passed over as expired or too short-dated, earliest expiry first */
  readonly skipped: readonly string[];
}

Your code names it in one line, in the file that uses it

import { fefoPick } from "#fune/inventory.expiry-fefo@^1";
impl/typescript.ts · 39 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { daysBetween } from "./dates_days_between.ts";  ← from dates.days-between ^1.0.0 · built alongside by fune
import { type FefoPick, type PickLine, type StockBatch } from "./inventory_expiry_fefo_types.ts";

function wholeUnits(name: string, value: number): void {
  if (!Number.isInteger(value) || value < 0) {
    throw new RangeError(`${name} must be a whole number, not negative, received ${value}`);
  }
}

/**
 * Pick `quantity` units earliest expiry first, from batches with at least
 * `minShelfLifeDays` of life left on `asAt`. Short stock is a shortfall, not
 * an error.
 */
export function fefoPick(batches: readonly StockBatch[], quantity: number, asAt: string, minShelfLifeDays: number): FefoPick {
  wholeUnits("quantity", quantity);
  wholeUnits("minShelfLifeDays", minShelfLifeDays);
  const dated = batches.map((batch, index) => {
    wholeUnits(`quantity of batch "${batch.batch}"`, batch.quantity);
    return { batch, index, life: daysBetween(asAt, batch.expiry) };
  });
  // Array.prototype.sort is stable, so equal expiry dates keep their order.
  dated.sort((a, b) => a.life - b.life);
  const lines: PickLine[] = [];
  const skipped: string[] = [];
  let remaining = quantity;
  for (const { batch, life } of dated) {
    if (batch.quantity === 0) continue;
    if (life < minShelfLifeDays) {
      skipped.push(batch.batch);
      continue;
    }
    if (remaining === 0) continue;
    const take = Math.min(remaining, batch.quantity);
    lines.push({ batch: batch.batch, expiry: batch.expiry, quantity: take });
    remaining -= take;
  }
  return { lines, picked: quantity - remaining, shortfall: remaining, skipped };
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add inventory.expiry-fefo
Download for TypeScript inventory.expiry-fefo-1.0.0-typescript.fune · 13,551 bytes sha256 e1a2d9d23b1ac23314e0dbce2a199cc105ddb46ed6e8214391a1679b08722e14

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./inventory.expiry-fefo-1.0.0-typescript.fune, or fetch it from a terminal with fune pull inventory.expiry-fefo@1.0.0:typescript.

The whole function, every language, is one file too: inventory.expiry-fefo-1.0.0.fune, 18,600 bytes, sha256 9bbf73c5c2b456aff2db9af2961110453eebfcea6ecd6ff8937a4798b3abdda0. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before inventory.expiry-fefo

after — your function gets the result and the arguments, and returns the final result.

// fune: after inventory.expiry-fefo

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace dates.days-between in inventory.expiry-fefo

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show inventory.expiry-fefo --steps.

// fune: step inventory.expiry-fefo after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
earliest expiry first, expired B3 skipped, B2 and B4 share a date and go in the order given batches ×5, 12, 2026-09-23, 0 → lines ×2, picked 12, shortfall 0, skipped B3
a 10-day minimum shelf life skips B2 and B4 (7 days left) as well batches ×5, 12, 2026-09-23, 10 → lines ×2, picked 12, shortfall 0, skipped B3, B2, B4
not enough in date: everything usable is picked and the rest is a shortfall batches ×5, 50, 2026-09-23, 0 → lines ×4, picked 38, shortfall 12, skipped B3
stock expiring on the pick date is still usable with no minimum shelf life batches ×2, 3, 2026-09-23, 0 → lines ×2, picked 3, shortfall 0, skipped
a minimum of 1 day excludes stock expiring on the pick date batches ×2, 3, 2026-09-23, 1 → lines ×1, picked 3, shortfall 0, skipped TODAY
shelf life counts across a leap day: 2028-02-19 to 2028-02-29 is 10 days batches ×1, 4, 2028-02-19, 10 → lines ×1, picked 4, shortfall 0, skipped
across a non-leap February end, 2027-02-19 to 2027-03-01 is only 10 days, short of an 11-day minimum batches ×1, 4, 2027-02-19, 11 → lines , picked 0, shortfall 4, skipped MARCH
a batch with no units is neither picked nor skipped batches ×3, 2, 2026-09-23, 0 → lines ×1, picked 2, shortfall 0, skipped
asking for nothing picks nothing, but still reports the out-of-date batches batches ×5, 0, 2026-09-23, 0 → lines , picked 0, shortfall 0, skipped B3
no batches at all is all shortfall , 7, 2026-09-23, 0 → lines , picked 0, shortfall 7, skipped
Show the other 5 tests
CaseArgumentsExpected
a negative request is an error batches ×5, -1, 2026-09-23, 0 → error: quantity must be a whole number, not negative
a fractional request is an error batches ×5, 1.5, 2026-09-23, 0 → error: quantity must be a whole number, not negative
a negative shelf life is an error batches ×5, 1, 2026-09-23, -1 → error: minShelfLifeDays must be a whole number, not negative
a batch with negative stock is an error batches ×1, 1, 2026-09-23, 0 → error: quantity of batch "NEG" must be a whole number, not negative
an impossible expiry date is an error batches ×1, 1, 2026-09-23, 0 → error: is not a real calendar date

More from the author

**Order.** Usable batches are taken earliest expiry first; batches with the same expiry are taken in the order given (sort them by location or receipt date first if that matters). A batch is used up before the next is started, so the list has at most one part-used batch, the last.

**Not enough stock is an answer, not an error.** The list picks everything usable and reports the rest as `shortfall`, which a warehouse needs to back-order or short-ship. Batches with no units are ignored, neither picked nor skipped. Negative quantities, a negative shelf life and malformed dates are errors.

This works in whole units. It does not reserve stock, so two picks run from the same batch list will both take the same units.

Source: FEFO as described in the EU Good Distribution Practice guidelines (2013/C 343/01), section 5.5, and the GS1 General Specifications for expiry dates (AI 17).

Files

PathBytes
README.md1,578
impl/python.py1,649
impl/rust.rs3,195
impl/typescript.ts1,597
vectors.json6,011