use super::funejson::Value; use super::math_round_div::round_div; use super::money_amount::{money, money_from_value, money_to_value, Money}; use super::money_compare::compare_money; use super::retail_volumetric_weight::chargeable_weight; 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 chargeable_in(band: &ShippingBand, parcel: &Parcel) -> i64 { match band.volumetric_divisor { None => round_div(parcel.actual_grams, band.round_up_to_grams, "up") * band.round_up_to_grams, Some(divisor) => { chargeable_weight( parcel.length_mm, parcel.width_mm, parcel.height_mm, parcel.actual_grams, divisor, band.round_up_to_grams, ) .chargeable_grams } } } /// The delivery charge for a parcel: the smallest band on the rate card in /// force on the order date that takes it, free when the basket reaches the /// band's threshold. /// /// # Panics /// Panics on a malformed date or parcel, mixed currencies, or when no band /// takes the parcel. pub fn shipping_rate( bands: &[ShippingBand], service: &str, zone: &str, parcel: &Parcel, basket_total: &Money, on_date: &str, ) -> ShippingQuote { if !is_iso_date(on_date) { panic!("onDate must be an ISO date (YYYY-MM-DD), received \"{}\"", on_date); } for d in [parcel.length_mm, parcel.width_mm, parcel.height_mm] { if d < 1 { panic!("dimensions must be 1 mm or more, received {}", d); } } if parcel.actual_grams < 0 { panic!("actualGrams must not be negative, received {}", parcel.actual_grams); } let longest = parcel.length_mm.max(parcel.width_mm).max(parcel.height_mm); let mut best: Option<&ShippingBand> = None; let mut best_grams = 0; for band in bands { if band.service != service || band.zone != zone { continue; } if on_date < band.valid_from.as_str() { continue; } if let Some(to) = &band.valid_to { if on_date > to.as_str() { continue; } } if let Some(max_length) = band.max_length_mm { if longest > max_length { continue; } } let grams = chargeable_in(band, parcel); if grams > band.max_grams { continue; } let better = match best { None => true, Some(b) => { band.max_grams < b.max_grams || (band.max_grams == b.max_grams && compare_money(&band.price, &b.price) < 0) } }; if better { best = Some(band); best_grams = grams; } } let best = match best { Some(b) => b, None => panic!( "no shipping band for service \"{}\" to zone \"{}\" on {} fits this parcel", service, zone, on_date ), }; let free = match &best.free_over { Some(threshold) => compare_money(basket_total, threshold) >= 0, None => false, }; ShippingQuote { service: service.to_string(), zone: zone.to_string(), chargeable_grams: best_grams, band_max_grams: best.max_grams, standard_price: best.price.clone(), price: if free { money(0, &best.price.currency) } else { best.price.clone() }, free, } } fn opt_int(v: &Value) -> Option { if v.is_null() { None } else { Some(v.as_i64()) } } pub fn shipping_band_from_value(v: &Value) -> ShippingBand { ShippingBand { service: v.get("service").as_str().to_string(), zone: v.get("zone").as_str().to_string(), max_grams: v.get("maxGrams").as_i64(), max_length_mm: opt_int(v.get("maxLengthMm")), volumetric_divisor: opt_int(v.get("volumetricDivisor")), round_up_to_grams: v.get("roundUpToGrams").as_i64(), price: money_from_value(v.get("price")), free_over: if v.get("freeOver").is_null() { None } else { Some(money_from_value(v.get("freeOver"))) }, valid_from: v.get("validFrom").as_str().to_string(), valid_to: if v.get("validTo").is_null() { None } else { Some(v.get("validTo").as_str().to_string()) }, } } pub fn parcel_from_value(v: &Value) -> Parcel { Parcel { length_mm: v.get("lengthMm").as_i64(), width_mm: v.get("widthMm").as_i64(), height_mm: v.get("heightMm").as_i64(), actual_grams: v.get("actualGrams").as_i64(), } } pub fn shipping_quote_to_value(q: &ShippingQuote) -> Value { Value::obj(vec![ ("service", Value::str(&q.service)), ("zone", Value::str(&q.zone)), ("chargeableGrams", Value::Int(q.chargeable_grams)), ("bandMaxGrams", Value::Int(q.band_max_grams)), ("standardPrice", money_to_value(&q.standard_price)), ("price", money_to_value(&q.price)), ("free", Value::Bool(q.free)), ]) } pub fn fune_vector(args: &[Value]) -> Value { let bands: Vec = args[0].as_arr().iter().map(shipping_band_from_value).collect(); shipping_quote_to_value(&shipping_rate( &bands, args[1].as_str(), args[2].as_str(), &parcel_from_value(&args[3]), &money_from_value(&args[4]), args[5].as_str(), )) }