inventory.cycle-count-schedule
Cycle count schedule: spread each item's counts evenly over the working days of a period, by ABC class frequency.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 17 tests, run in TypeScript, Python and Rust.
What it does
Cycle counting replaces the annual stocktake with small counts every working day, counting valuable (class A) items more often than cheap ones. This builds the calendar: given each item's class and how many times a class is counted in the period, it says which items to count on each working day.
**Working days** are Monday to Friday from `startDate` to `endDate` inclusive, less the `holidays` (pass `dates.bank-holidays` for the UK). The schedule lists every working day, including days with nothing to count, so a count team can see its load.
For example
cycle_count_schedule(items ×6, a 2, b 1, c 0, 2026-09-21, 2026-10-02, )→ ×10 two weeks: A twice (days 0-4 and 5-9), B once over ten days, C nevercycle_count_schedule(items ×4, a 3, 2026-12-21, 2027-01-01, 2026-12-25, 2026-12-28, 2027-01-01)→ ×7 Christmas: weekends and the 25th, 28th (Boxing Day substitute) and 1st are skipped, leaving 7 days in windows of 2, 2 and 3cycle_count_schedule(items ×1, a 3, 2026-09-23, 2026-09-25, )→ ×3 counted as often as there are working days: every day
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.
def cycle_count_schedule(items: Sequence[CountItem], counts_per_period: Mapping[str, int], start_date: str, end_date: str, holidays: Sequence[str]) -> List[CountDay]
| items | CountItem[] | every item to count, with its class, e.g. from inventory.abc-classification |
| counts_per_period | map<int> | how many times each class is counted in the period: {"A": 12, "B": 4, "C": 1} |
| start_date | date | first day of the period |
| end_date | date | last day of the period, inclusive |
| holidays | date[] | non-working dates, e.g. from dates.bank-holidays |
| returns | CountDay[] | every working day of the period in date order, with the items to count that day |
The types it declares, generated into your project
@dataclass(frozen=True)
class CountItem:
"""One item to count."""
#: unique
sku: str
#: a key of countsPerPeriod
abc_class: str
@dataclass(frozen=True)
class CountDay:
"""One working day of the schedule."""
date: str
#: items to count, by class then sku; empty on a day with nothing to count
skus: List[str]
Your code names it in one line, in the file that uses it
from fune.inventory.cycle_count_schedule import cycle_count_schedule # inventory.cycle-count-schedule@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import Dict, List, Mapping, Sequence
from .dates_add_days import epoch_day_from_iso, iso_from_epoch_day ← from dates.add-days ^1.0.0 · built alongside by fune
from .dates_day_of_week import day_of_week ← from dates.day-of-week ^1.0.0 · built alongside by fune
from .inventory_cycle_count_schedule_types import CountDay, CountItem
def cycle_count_schedule(
items: Sequence[CountItem],
counts_per_period: Mapping[str, int],
start_date: str,
end_date: str,
holidays: Sequence[str],
) -> List[CountDay]:
"""Spread each item's counts evenly over the working days of a period: a
class counted f times gets f even windows, and its items are spread evenly
within each window.
"""
first = epoch_day_from_iso(start_date)
last = epoch_day_from_iso(end_date)
if last < first:
raise ValueError("endDate %s is before startDate %s" % (end_date, start_date))
closed = {epoch_day_from_iso(h) for h in holidays}
days: List[str] = []
for day in range(first, last + 1):
iso = iso_from_epoch_day(day)
if day_of_week(iso) <= 5 and day not in closed:
days.append(iso)
classes = sorted(counts_per_period.keys())
for cls in classes:
f = counts_per_period[cls]
if isinstance(f, bool) or not isinstance(f, int) or f < 0:
raise ValueError(
'countsPerPeriod for class "%s" must be a whole number, not negative, received %r' % (cls, f)
)
by_class: Dict[str, List[str]] = {}
seen = set()
for item in items:
if item.sku in seen:
raise ValueError('duplicate sku "%s"' % item.sku)
seen.add(item.sku)
if item.abc_class not in counts_per_period:
raise ValueError('no count frequency for class "%s" (sku "%s")' % (item.abc_class, item.sku))
by_class.setdefault(item.abc_class, []).append(item.sku)
n = len(days)
schedule: List[List[str]] = [[] for _ in days]
for cls in classes:
skus = sorted(by_class.get(cls, []))
f = counts_per_period[cls]
if not skus or f == 0:
continue
if f > n:
raise ValueError('class "%s" is counted %d times but the period has only %d working days' % (cls, f, n))
for k in range(f):
start = k * n // f
length = (k + 1) * n // f - start
for j, sku in enumerate(skus):
schedule[start + j * length // len(skus)].append(sku)
return [CountDay(date=date, skus=schedule[i]) for i, date in enumerate(days)]Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Python 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.cycle-count-schedule
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./inventory.cycle-count-schedule-1.0.0-python.fune, or fetch it from a terminal with fune pull inventory.cycle-count-schedule@1.0.0:python.
The whole function, every language, is one file too: inventory.cycle-count-schedule-1.0.0.fune, 20,209 bytes, sha256 28d643e77bd3b58fea5f1e3246191548aa9c02667cc931fd33fe53ce5e5bdd87. 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.cycle-count-schedule
after — your function gets the result and the arguments, and returns the final result.
# fune: after inventory.cycle-count-schedule
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.add-days in inventory.cycle-count-schedule
# fune: replace dates.day-of-week in inventory.cycle-count-schedule
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.cycle-count-schedule --steps.
# fune: step inventory.cycle-count-schedule 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 | |
|---|---|---|---|
| two weeks: A twice (days 0-4 and 5-9), B once over ten days, C never | items ×6, a 2, b 1, c 0, 2026-09-21, 2026-10-02, | → | ×10 |
| Christmas: weekends and the 25th, 28th (Boxing Day substitute) and 1st are skipped, leaving 7 days in windows of 2, 2 and 3 | items ×4, a 3, 2026-12-21, 2027-01-01, 2026-12-25, 2026-12-28, 2027-01-01 | → | ×7 |
| counted as often as there are working days: every day | items ×1, a 3, 2026-09-23, 2026-09-25, | → | ×3 |
| skus sort by character code, so B10 comes before B2 | items ×2, b 1, 2026-09-21, 2026-09-22, | → | ×2 |
| a class sorts before another on a shared day whatever the map order | items ×2, b 1, a 1, 2026-09-21, 2026-09-21, | → | ×1 |
| a holiday that falls on a weekend changes nothing, and a one-day period works | items ×1, a 1, 2026-09-25, 2026-09-27, 2026-09-26 | → | ×1 |
| no items: every working day, all empty | , a 12, 2026-09-24, 2026-09-28, | → | ×3 |
| a weekend-only period with nothing to count is an empty schedule | items ×1, c 0, 2026-09-26, 2026-09-27, | → | |
| across a leap day: 2028-02-28 to 2028-03-01 is three working days | items ×3, a 1, 2028-02-28, 2028-03-01, | → | ×3 |
| a class counted more often than there are working days is an error | items ×1, a 4, 2026-09-23, 2026-09-25, | → | error: class "A" is counted 4 times but the period has only 3 working days |
Show the other 7 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| something to count in a weekend-only period is an error | items ×1, a 1, 2026-09-26, 2026-09-27, | → | error: the period has only 0 working days |
| an item whose class has no frequency is an error | items ×1, a 1, 2026-09-21, 2026-09-25, | → | error: no count frequency for class "D" (sku "D1") |
| a duplicate sku is an error | items ×2, a 1, b 1, 2026-09-21, 2026-09-25, | → | error: duplicate sku "A1" |
| a negative frequency is an error | , a -1, 2026-09-21, 2026-09-25, | → | error: countsPerPeriod for class "A" must be a whole number, not negative |
| a fractional frequency is an error | , a 1.5, 2026-09-21, 2026-09-25, | → | error: countsPerPeriod for class "A" must be a whole number, not negative |
| a period that ends before it starts is an error | , a 1, 2026-09-25, 2026-09-21, | → | error: endDate 2026-09-21 is before startDate 2026-09-25 |
| an impossible holiday is an error | , a 1, 2026-09-21, 2026-09-25, 2026-02-30 | → | error: is not a real calendar date |
More from the author
**Spreading.** For a class counted `f` times over `N` working days, the days are cut into `f` consecutive windows as evenly as whole days allow (window `k` starts on day `floor(k x N / f)`), so the counts of one item are roughly `N / f` days apart. Within each window the class's items, in SKU order, are spread evenly: item `j` of `m` goes on day `floor(j x length / m)` of the window. Each class is spread on its own, so the daily load is the sum of even spreads. The rule is plain integer arithmetic, so a schedule can be checked by hand and is the same in every language.
A class counted 0 times is never scheduled. A class counted more often than there are working days is an error (it would need two counts of one item on one day); so are an item whose class has no frequency, a duplicate SKU, a negative frequency, a period that ends before it starts, and malformed dates. Items on a day are listed by class, then SKU, by character code (keep them ASCII for the same order everywhere).
Typical frequencies: A monthly (12 a year), B quarterly (4), C yearly (1). The schedule does not carry counts across periods: a yearly schedule is built once for the year.
Source: the practice of ABC-based cycle counting, e.g. APICS Dictionary ("cycle counting") and Silver, Pyke and Thomas, *Inventory and Production Management in Supply Chains*, 4th ed.
Files
| Path | Bytes |
|---|---|
| README.md | 1,935 |
| impl/python.py | 2,452 |
| impl/rust.rs | 4,274 |
| impl/typescript.ts | 2,662 |
| vectors.json | 4,903 |