inventory.abc-classification@1.0.0
impl/python.py
2,940 bytes · the Python implementation · view raw
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from functools import cmp_to_key
from typing import List, Sequence
from .inventory_abc_classification_types import AbcItem, ConsumptionItem
from .math_round_div import round_div ← from math.round-div ^1.0.0 · built alongside by fune
from .money_amount import assert_same_currency, money ← from money.amount ^1.0.0 · built alongside by fune
LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
MAX_SAFE = 2**53 - 1
def _by_value_then_sku(a: tuple, b: tuple) -> int:
if a[1] != b[1]:
return -1 if a[1] > b[1] else 1
return -1 if a[0] < b[0] else 1 if a[0] > b[0] else 0
def abc_classification(items: Sequence[ConsumptionItem], cutoff_basis_points: Sequence[int]) -> List[AbcItem]:
"""Rank items by annual consumption value and class them by the band of the
cumulative share each one starts in, so the item that crosses a cut-off
stays in the higher class.
"""
if len(cutoff_basis_points) == 0 or len(cutoff_basis_points) > 25:
raise ValueError("cutoffBasisPoints needs 1 to 25 cut-offs, received %d" % len(cutoff_basis_points))
previous = 0
for cutoff in cutoff_basis_points:
if isinstance(cutoff, bool) or not isinstance(cutoff, int) or cutoff <= previous or cutoff >= 10000:
raise ValueError(
"cutoffBasisPoints must ascend strictly within 1..9999: received %s after %s" % (cutoff, previous)
)
previous = cutoff
seen = set()
valued = []
for item in items:
if item.sku in seen:
raise ValueError('duplicate sku "%s"' % item.sku)
seen.add(item.sku)
q = item.annual_quantity
if isinstance(q, bool) or not isinstance(q, int) or q < 0:
raise ValueError(
'annualQuantity must be a whole number, not negative, received %r for "%s"' % (q, item.sku)
)
if item.unit_cost.minor < 0:
raise ValueError('unitCost must not be negative, received %d for "%s"' % (item.unit_cost.minor, item.sku))
assert_same_currency(items[0].unit_cost, item.unit_cost)
valued.append((item.sku, q * item.unit_cost.minor, item.unit_cost.currency))
total = sum(v[1] for v in valued)
if total * 10000 > MAX_SAFE:
raise ValueError("the total annual value is too large: it must stay within (2^53 - 1) / 10000 minor units")
valued.sort(key=cmp_to_key(_by_value_then_sku))
result: List[AbcItem] = []
before = 0
for index, (sku, value, currency) in enumerate(valued):
band = len(cutoff_basis_points)
for i, cutoff in enumerate(cutoff_basis_points):
if before * 10000 < cutoff * total:
band = i
break
before += value
result.append(
AbcItem(
sku=sku,
annual_value=money(value, currency),
rank=index + 1,
cumulative_basis_points=0 if total == 0 else round_div(before * 10000, total, "half-up"),
abc_class=LETTERS[band],
)
)
return result