use super::model::{CommercialPrecursorOffer, CurrencyCode, Money};
use crate::composition::Composition;
pub(crate) fn molar_mass_g_per_mol(composition: &Composition) -> f64 {
composition
.iter()
.map(|(element, amount)| crate::thermodynamics::atomic_weight_amu(element) * amount)
.sum()
}
pub(crate) fn unresolved_fields_for(offer: &CommercialPrecursorOffer) -> Vec<&'static str> {
let mut fields = Vec::new();
if offer.purity.is_none() {
fields.push("purity");
}
if offer.package_mass.is_none() {
fields.push("package_mass");
}
if offer.unit_price.is_none() {
fields.push("unit_price");
}
if offer.lead_time_days.is_none() {
fields.push("lead_time_days");
}
fields
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct OfferQuantity {
pub(crate) purity_adjusted_purchase_mass_grams: Option<f64>,
pub(crate) package_count: Option<u64>,
pub(crate) purchased_mass_grams: Option<f64>,
pub(crate) excess_mass_grams: Option<f64>,
pub(crate) subtotal: Option<Money>,
pub(crate) cost_overflowed: bool,
}
pub(crate) fn compute_offer_quantity(
offer: &CommercialPrecursorOffer,
theoretical_pure_mass_required_grams: f64,
) -> OfferQuantity {
let purity_adjusted_purchase_mass_grams = offer
.purity
.map(|p| theoretical_pure_mass_required_grams / p.value());
let package_count = match (purity_adjusted_purchase_mass_grams, offer.package_mass) {
(Some(mass), Some(pkg)) => Some((mass / pkg.grams()).ceil().max(0.0) as u64),
_ => None,
};
let purchased_mass_grams = package_count
.zip(offer.package_mass)
.map(|(count, pkg)| count as f64 * pkg.grams());
let excess_mass_grams = purchased_mass_grams
.zip(purity_adjusted_purchase_mass_grams)
.map(|(purchased, required)| purchased - required);
let mut cost_overflowed = false;
let subtotal = match (offer.unit_price, package_count) {
(Some(price), Some(count)) => match price.checked_mul_quantity(count) {
Some(money) => Some(money),
None => {
cost_overflowed = true;
None
}
},
_ => None,
};
OfferQuantity {
purity_adjusted_purchase_mass_grams,
package_count,
purchased_mass_grams,
excess_mass_grams,
subtotal,
cost_overflowed,
}
}
pub(crate) fn cost_rank_key(subtotal: Option<Money>) -> (u8, Option<CurrencyCode>, u64) {
match subtotal {
Some(money) => (0, Some(money.currency()), money.minor_units()),
None => (1, None, 0),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commercial_catalog::test_support::*;
#[test]
fn package_count_does_not_round_up_when_exactly_at_a_package_boundary() {
let offer = priced_offer(
"A",
"BaCO3",
"Example Materials Ltd.",
Some(1.0),
Some(100.0),
Some((1000, "USD")),
Some(5),
None,
);
let quantity = compute_offer_quantity(&offer, 500.0);
assert_eq!(
quantity.package_count,
Some(5),
"exact divisibility must not round up an extra package"
);
}
#[test]
fn package_count_rounds_up_when_just_over_a_package_boundary() {
let offer = priced_offer(
"A",
"BaCO3",
"Example Materials Ltd.",
Some(1.0),
Some(100.0),
Some((1000, "USD")),
Some(5),
None,
);
let quantity = compute_offer_quantity(&offer, 500.01);
assert_eq!(quantity.package_count, Some(6));
}
}