use std::collections::HashSet; use super::funejson::Value; use super::math_round_div::round_div; use super::money_amount::{assert_same_currency, money, money_from_value, money_to_value}; const LETTERS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const MAX_SAFE: i128 = (1i128 << 53) - 1; /// 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. /// /// # Panics /// Panics on bad cut-offs, a duplicate SKU, a negative quantity or cost, mixed /// currencies, or a total too large to scale by 10000. pub fn abc_classification(items: &[ConsumptionItem], cutoff_basis_points: &[i64]) -> Vec { if cutoff_basis_points.is_empty() || cutoff_basis_points.len() > 25 { panic!("cutoffBasisPoints needs 1 to 25 cut-offs, received {}", cutoff_basis_points.len()); } let mut previous = 0; for &cutoff in cutoff_basis_points { if cutoff <= previous || cutoff >= 10000 { panic!( "cutoffBasisPoints must ascend strictly within 1..9999: received {} after {}", cutoff, previous ); } previous = cutoff; } let mut seen: HashSet<&str> = HashSet::new(); let mut valued: Vec<(String, i64, String)> = Vec::new(); for item in items { if !seen.insert(item.sku.as_str()) { panic!("duplicate sku \"{}\"", item.sku); } if item.annual_quantity < 0 { panic!( "annualQuantity must be a whole number, not negative, received {} for \"{}\"", item.annual_quantity, item.sku ); } if item.unit_cost.minor < 0 { panic!("unitCost must not be negative, received {} for \"{}\"", item.unit_cost.minor, item.sku); } assert_same_currency(&items[0].unit_cost, &item.unit_cost); valued.push((item.sku.clone(), item.annual_quantity * item.unit_cost.minor, item.unit_cost.currency.clone())); } let total: i64 = valued.iter().map(|v| v.1).sum(); if total as i128 * 10000 > MAX_SAFE { panic!("the total annual value is too large: it must stay within (2^53 - 1) / 10000 minor units"); } valued.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); let mut before: i64 = 0; let mut result = Vec::with_capacity(valued.len()); for (index, (sku, value, currency)) in valued.into_iter().enumerate() { let band = cutoff_basis_points .iter() .position(|&cutoff| (before as i128) * 10000 < (cutoff as i128) * (total as i128)) .unwrap_or(cutoff_basis_points.len()); before += value; result.push(AbcItem { sku, annual_value: money(value, ¤cy), rank: index as i64 + 1, cumulative_basis_points: if total == 0 { 0 } else { round_div(before * 10000, total, "half-up") }, abc_class: (LETTERS[band] as char).to_string(), }); } result } pub fn consumption_item_from_value(v: &Value) -> ConsumptionItem { ConsumptionItem { sku: v.get("sku").as_str().to_string(), annual_quantity: v.get("annualQuantity").as_i64(), unit_cost: money_from_value(v.get("unitCost")), } } pub fn abc_item_to_value(item: &AbcItem) -> Value { Value::obj(vec![ ("sku", Value::str(&item.sku)), ("annualValue", money_to_value(&item.annual_value)), ("rank", Value::Int(item.rank)), ("cumulativeBasisPoints", Value::Int(item.cumulative_basis_points)), ("abcClass", Value::str(&item.abc_class)), ]) } pub fn fune_vector(args: &[Value]) -> Value { let items: Vec = args[0] .as_arr() .iter() .map(|v| { if let Value::Float(f) = v.get("annualQuantity") { panic!( "annualQuantity must be a whole number, not negative, received {} for \"{}\"", f, v.get("sku").as_str() ); } consumption_item_from_value(v) }) .collect(); let cutoffs: Vec = args[1].as_arr().iter().map(|v| v.as_i64()).collect(); Value::Arr(abc_classification(&items, &cutoffs).iter().map(abc_item_to_value).collect()) }