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
fefo_pick(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 givenfefo_pick(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 wellfefo_pick(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.
pub fn fefo_pick(batches: &[StockBatch], quantity: i64, as_at: &str, min_shelf_life_days: i64) -> FefoPick
| batches | StockBatch[] | stock on hand by batch, in any order; equal expiry dates are picked in this order |
| quantity | int | units wanted |
| as_at | date | the pick date |
| min_shelf_life_days | int | days of life a unit must have left on asAt: 0 allows stock expiring that day |
| returns | FefoPick | what to pick, oldest expiry first |
The types it declares, generated into your project
/// Units of one batch or lot on hand.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StockBatch {
pub batch: String,
/// the last day the stock may be used or sold
pub expiry: String,
/// units on hand, not negative
pub quantity: i64,
}
/// Units to take from one batch.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickLine {
pub batch: String,
pub expiry: String,
pub quantity: i64,
}
/// The pick list, and what could not be picked.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FefoPick {
/// in pick order, earliest expiry first
pub lines: Vec<PickLine>,
/// units on the list
pub picked: i64,
/// units wanted but not available in date; 0 when the request is met
pub shortfall: i64,
/// batches passed over as expired or too short-dated, earliest expiry first
pub skipped: Vec<String>,
}
Your code names it in one line, in the file that uses it
fune!(inventory.expiry-fefo@^1); // then call fefo_pick(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_days_between::days_between; ← from dates.days-between ^1.0.0 · built alongside by fune
fn whole_units(name: &str, value: i64) {
if value < 0 {
panic!("{} must be a whole number, not negative, received {}", name, value);
}
}
/// Pick `quantity` units earliest expiry first, from batches with at least
/// `min_shelf_life_days` of life left on `as_at`. Short stock is a shortfall,
/// not an error.
///
/// # Panics
/// Panics on a negative quantity or shelf life, or a malformed date.
pub fn fefo_pick(batches: &[StockBatch], quantity: i64, as_at: &str, min_shelf_life_days: i64) -> FefoPick {
whole_units("quantity", quantity);
whole_units("minShelfLifeDays", min_shelf_life_days);
let mut dated: Vec<(i64, &StockBatch)> = batches
.iter()
.map(|batch| {
whole_units(&format!("quantity of batch \"{}\"", batch.batch), batch.quantity);
(days_between(as_at, &batch.expiry), batch)
})
.collect();
// sort_by_key is stable, so equal expiry dates keep their order.
dated.sort_by_key(|pair| pair.0);
let mut lines = Vec::new();
let mut skipped = Vec::new();
let mut remaining = quantity;
for (life, batch) in dated {
if batch.quantity == 0 {
continue;
}
if life < min_shelf_life_days {
skipped.push(batch.batch.clone());
continue;
}
if remaining == 0 {
continue;
}
let take = remaining.min(batch.quantity);
lines.push(PickLine {
batch: batch.batch.clone(),
expiry: batch.expiry.clone(),
quantity: take,
});
remaining -= take;
}
FefoPick {
lines,
picked: quantity - remaining,
shortfall: remaining,
skipped,
}
}
pub fn stock_batch_from_value(v: &Value) -> StockBatch {
StockBatch {
batch: v.get("batch").as_str().to_string(),
expiry: v.get("expiry").as_str().to_string(),
quantity: v.get("quantity").as_i64(),
}
}
pub fn fefo_pick_to_value(result: &FefoPick) -> Value {
Value::obj(vec![
(
"lines",
Value::Arr(
result
.lines
.iter()
.map(|l| {
Value::obj(vec![
("batch", Value::str(&l.batch)),
("expiry", Value::str(&l.expiry)),
("quantity", Value::Int(l.quantity)),
])
})
.collect(),
),
),
("picked", Value::Int(result.picked)),
("shortfall", Value::Int(result.shortfall)),
("skipped", Value::Arr(result.skipped.iter().map(|s| Value::str(s)).collect())),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
if let Value::Float(f) = &args[1] {
panic!("quantity must be a whole number, not negative, received {}", f);
}
let batches: Vec<StockBatch> = args[0].as_arr().iter().map(stock_batch_from_value).collect();
fefo_pick_to_value(&fefo_pick(&batches, args[1].as_i64(), args[2].as_str(), args[3].as_i64()))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add inventory.expiry-fefo
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./inventory.expiry-fefo-1.0.0-rust.fune, or fetch it from a terminal with fune pull inventory.expiry-fefo@1.0.0:rust.
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,578 |
| impl/python.py | 1,649 |
| impl/rust.rs | 3,195 |
| impl/typescript.ts | 1,597 |
| vectors.json | 6,011 |