inventory.abc-classification@1.0.0
impl/typescript.ts
2,597 bytes · the TypeScript 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.
import { roundDiv } from "./math_round_div.ts"; ← from math.round-div ^1.0.0 · built alongside by fune
import { assertSameCurrency, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { type AbcItem, type ConsumptionItem } from "./inventory_abc_classification_types.ts";
const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
/**
* 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.
*/
export function abcClassification(items: readonly ConsumptionItem[], cutoffBasisPoints: readonly number[]): readonly AbcItem[] {
if (cutoffBasisPoints.length === 0 || cutoffBasisPoints.length > 25) {
throw new RangeError(`cutoffBasisPoints needs 1 to 25 cut-offs, received ${cutoffBasisPoints.length}`);
}
let previous = 0;
for (const cutoff of cutoffBasisPoints) {
if (!Number.isInteger(cutoff) || cutoff <= previous || cutoff >= 10000) {
throw new RangeError(`cutoffBasisPoints must ascend strictly within 1..9999: received ${cutoff} after ${previous}`);
}
previous = cutoff;
}
const seen = new Set<string>();
const valued = items.map((item) => {
if (seen.has(item.sku)) throw new RangeError(`duplicate sku "${item.sku}"`);
seen.add(item.sku);
if (!Number.isInteger(item.annualQuantity) || item.annualQuantity < 0) {
throw new RangeError(`annualQuantity must be a whole number, not negative, received ${item.annualQuantity} for "${item.sku}"`);
}
if (item.unitCost.minor < 0) {
throw new RangeError(`unitCost must not be negative, received ${item.unitCost.minor} for "${item.sku}"`);
}
assertSameCurrency(items[0].unitCost, item.unitCost);
return { sku: item.sku, value: item.annualQuantity * item.unitCost.minor, currency: item.unitCost.currency };
});
const total = valued.reduce((sum, item) => sum + item.value, 0);
if (!Number.isSafeInteger(total * 10000)) {
throw new RangeError("the total annual value is too large: it must stay within (2^53 - 1) / 10000 minor units");
}
valued.sort((a, b) => (b.value !== a.value ? b.value - a.value : a.sku < b.sku ? -1 : a.sku > b.sku ? 1 : 0));
let before = 0;
return valued.map((item, index) => {
let band = cutoffBasisPoints.findIndex((cutoff) => before * 10000 < cutoff * total);
if (band < 0) band = cutoffBasisPoints.length;
before += item.value;
return {
sku: item.sku,
annualValue: money(item.value, item.currency),
rank: index + 1,
cumulativeBasisPoints: total === 0 ? 0 : roundDiv(before * 10000, total, "half-up"),
abcClass: LETTERS[band],
};
});
}