use super::funejson::Value; use super::math_round_div::round_div; use super::money_add::add_money; use super::money_amount::{assert_same_currency, money, money_from_value, money_to_value, Money}; use super::money_apply_rate::apply_rate; use super::money_compare::compare_money; fn is_iso_date(value: &str) -> bool { let bytes = value.as_bytes(); bytes.len() == 10 && bytes[4] == b'-' && bytes[7] == b'-' && bytes .iter() .enumerate() .all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit()) } fn in_force(valid_from: &str, valid_to: &Option, on_date: &str) -> bool { on_date >= valid_from && match valid_to { None => true, Some(to) => on_date <= to.as_str(), } } fn round_up_to(grams: i64, step: i64) -> i64 { round_div(grams, step, "up") * step } /// The break's charge at a weight: base plus the per-kilogram element, at least the minimum. fn charge(rate: &FreightRate, rated_grams: i64) -> Money { let variable = round_div(rated_grams * rate.per_kg_minor, 1000, "half-up"); let freight = money(rate.base.minor + variable, &rate.base.currency); if let Some(minimum) = &rate.minimum { if compare_money(&freight, minimum) < 0 { return minimum.clone(); } } freight } /// Freight for a consignment on a caller-supplied tariff: the zone's break /// covering the weight on the ship date, or a heavier break when that is /// cheaper, plus the fuel surcharge in force that day. /// /// # Panics /// Panics on a weight below 1 g, a malformed date, a bad break, mixed /// currencies, no break or two breaks covering the weight, or a surcharge /// table with no row for the date. pub fn freight_rate( rates: &[FreightRate], fuel_surcharges: &[FuelSurcharge], zone: &str, chargeable_grams: i64, ship_date: &str, ) -> FreightQuote { if chargeable_grams < 1 { panic!("chargeableGrams must be 1 or more, received {}", chargeable_grams); } if !is_iso_date(ship_date) { panic!("shipDate must be an ISO date (YYYY-MM-DD), received \"{}\"", ship_date); } let candidates: Vec<&FreightRate> = rates .iter() .filter(|r| r.zone == zone && in_force(&r.valid_from, &r.valid_to, ship_date)) .collect(); for rate in &candidates { if rate.step_grams < 1 { panic!("stepGrams must be 1 or more, received {}", rate.step_grams); } if rate.per_kg_minor < 0 { panic!("perKgMinor must not be negative, received {}", rate.per_kg_minor); } assert_same_currency(&candidates[0].base, &rate.base); } let covering: Vec<&&FreightRate> = candidates .iter() .filter(|r| chargeable_grams >= r.from_grams && r.to_grams.map_or(true, |t| chargeable_grams <= t)) .collect(); if covering.is_empty() { panic!("no freight rate for zone \"{}\" on {} covers {} g", zone, ship_date, chargeable_grams); } if covering.len() > 1 { panic!("two freight rates for zone \"{}\" on {} cover {} g", zone, ship_date, chargeable_grams); } let mut best: &FreightRate = covering[0]; let mut best_grams = round_up_to(chargeable_grams, best.step_grams); let mut best_freight = charge(best, best_grams); // Rates per kilogram fall as weight rises, so a heavier break charged at // its first weight can undercut the break the consignment falls in. for rate in &candidates { if rate.from_grams <= chargeable_grams { continue; } let grams = round_up_to(rate.from_grams, rate.step_grams); let freight = charge(rate, grams); let order = compare_money(&freight, &best_freight); if order < 0 || (order == 0 && grams < best_grams) { best = rate; best_grams = grams; best_freight = freight; } } let mut basis_points = 0; if !fuel_surcharges.is_empty() { let mut found: Option<&FuelSurcharge> = None; for row in fuel_surcharges { if !in_force(&row.valid_from, &row.valid_to, ship_date) { continue; } if found.map_or(true, |f| row.valid_from > f.valid_from) { found = Some(row); } } // A table that stops short of the date has usually not been updated. match found { None => panic!("no fuel surcharge in force on {}", ship_date), Some(row) => basis_points = row.basis_points, } } let fuel_surcharge = apply_rate(&best_freight, basis_points, "half-up"); FreightQuote { zone: zone.to_string(), rated_grams: best_grams, break_from_grams: best.from_grams, total: add_money(&best_freight, &fuel_surcharge), freight: best_freight, fuel_surcharge_basis_points: basis_points, fuel_surcharge, } } fn opt_str(v: &Value) -> Option { if v.is_null() { None } else { Some(v.as_str().to_string()) } } pub fn freight_rate_from_value(v: &Value) -> FreightRate { FreightRate { zone: v.get("zone").as_str().to_string(), from_grams: v.get("fromGrams").as_i64(), to_grams: if v.get("toGrams").is_null() { None } else { Some(v.get("toGrams").as_i64()) }, step_grams: v.get("stepGrams").as_i64(), base: money_from_value(v.get("base")), per_kg_minor: v.get("perKgMinor").as_i64(), minimum: if v.get("minimum").is_null() { None } else { Some(money_from_value(v.get("minimum"))) }, valid_from: v.get("validFrom").as_str().to_string(), valid_to: opt_str(v.get("validTo")), } } pub fn fuel_surcharge_from_value(v: &Value) -> FuelSurcharge { FuelSurcharge { basis_points: v.get("basisPoints").as_i64(), valid_from: v.get("validFrom").as_str().to_string(), valid_to: opt_str(v.get("validTo")), } } pub fn freight_quote_to_value(q: &FreightQuote) -> Value { Value::obj(vec![ ("zone", Value::str(&q.zone)), ("ratedGrams", Value::Int(q.rated_grams)), ("breakFromGrams", Value::Int(q.break_from_grams)), ("freight", money_to_value(&q.freight)), ("fuelSurchargeBasisPoints", Value::Int(q.fuel_surcharge_basis_points)), ("fuelSurcharge", money_to_value(&q.fuel_surcharge)), ("total", money_to_value(&q.total)), ]) } pub fn fune_vector(args: &[Value]) -> Value { let rates: Vec = args[0].as_arr().iter().map(freight_rate_from_value).collect(); let fuel: Vec = args[1].as_arr().iter().map(fuel_surcharge_from_value).collect(); freight_quote_to_value(&freight_rate(&rates, &fuel, args[2].as_str(), args[3].as_i64(), args[4].as_str())) }