Functional Weave
Code in TypeScript

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],
    };
  });
}