use crate::composition::Composition;
use crate::precursor::PrecursorId;
use crate::report::{PlanId, WarningSeverity};
use std::collections::BTreeSet;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CommercialOfferId(pub String);
impl std::fmt::Display for CommercialOfferId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct PurityFraction(f64);
impl PurityFraction {
pub fn new(value: f64) -> Result<Self, CommercialCatalogError> {
if !value.is_finite() || value <= 0.0 || value > 1.0 {
return Err(CommercialCatalogError::InvalidPurity { value });
}
Ok(Self(value))
}
pub fn value(&self) -> f64 {
self.0
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for PurityFraction {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = f64::deserialize(deserializer)?;
PurityFraction::new(value).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct PackageMass(f64);
impl PackageMass {
pub fn new(grams: f64) -> Result<Self, CommercialCatalogError> {
if !grams.is_finite() || grams <= 0.0 {
return Err(CommercialCatalogError::InvalidPackageMass { value: grams });
}
Ok(Self(grams))
}
pub fn from_milligrams(mg: f64) -> Result<Self, CommercialCatalogError> {
Self::new(mg / 1_000.0)
}
pub fn from_kilograms(kg: f64) -> Result<Self, CommercialCatalogError> {
Self::new(kg * 1_000.0)
}
pub fn grams(&self) -> f64 {
self.0
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for PackageMass {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let grams = f64::deserialize(deserializer)?;
PackageMass::new(grams).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct ParticleSizeRangeUm {
pub min_um: f64,
pub max_um: f64,
}
impl ParticleSizeRangeUm {
pub fn new(min_um: f64, max_um: f64) -> Result<Self, CommercialCatalogError> {
if !min_um.is_finite() || !max_um.is_finite() {
return Err(CommercialCatalogError::InvalidParticleSizeRange {
reason: "min_um and max_um must both be finite".to_string(),
});
}
if min_um < 0.0 {
return Err(CommercialCatalogError::InvalidParticleSizeRange {
reason: format!("min_um must be >= 0, got {min_um}"),
});
}
if min_um > max_um {
return Err(CommercialCatalogError::InvalidParticleSizeRange {
reason: format!("min_um ({min_um}) must be <= max_um ({max_um})"),
});
}
Ok(Self { min_um, max_um })
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for ParticleSizeRangeUm {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize)]
struct Raw {
min_um: f64,
max_um: f64,
}
let raw = Raw::deserialize(deserializer)?;
ParticleSizeRangeUm::new(raw.min_um, raw.max_um).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CurrencyCode([u8; 3]);
impl CurrencyCode {
pub fn new(code: &str) -> Result<Self, CommercialCatalogError> {
let bytes = code.as_bytes();
if bytes.len() != 3 || !bytes.iter().all(u8::is_ascii_uppercase) {
return Err(CommercialCatalogError::InvalidCurrencyCode {
code: code.to_string(),
});
}
Ok(Self([bytes[0], bytes[1], bytes[2]]))
}
pub fn as_str(&self) -> &str {
std::str::from_utf8(&self.0).expect("CurrencyCode bytes are always valid ASCII")
}
}
impl std::fmt::Display for CurrencyCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for CurrencyCode {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for CurrencyCode {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
CurrencyCode::new(&s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Money {
minor_units: u64,
currency: CurrencyCode,
}
impl Money {
pub fn new(minor_units: u64, currency: CurrencyCode) -> Self {
Self {
minor_units,
currency,
}
}
pub fn minor_units(&self) -> u64 {
self.minor_units
}
pub fn currency(&self) -> CurrencyCode {
self.currency
}
pub fn checked_mul_quantity(&self, count: u64) -> Option<Money> {
self.minor_units
.checked_mul(count)
.map(|minor_units| Money {
minor_units,
currency: self.currency,
})
}
pub fn checked_add(&self, other: &Money) -> Option<Money> {
if self.currency != other.currency {
return None;
}
self.minor_units
.checked_add(other.minor_units)
.map(|minor_units| Money {
minor_units,
currency: self.currency,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CasNumber {
pub raw: String,
pub checksum_verified: bool,
}
impl CasNumber {
pub fn new(raw: &str) -> Self {
Self {
raw: raw.to_string(),
checksum_verified: cas_checksum_valid(raw),
}
}
}
fn cas_checksum_valid(raw: &str) -> bool {
let mut groups = raw.split('-');
let (Some(g1), Some(g2), Some(g3)) = (groups.next(), groups.next(), groups.next()) else {
return false;
};
if groups.next().is_some() {
return false;
}
if !(2..=7).contains(&g1.len()) || !g1.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
if g2.len() != 2 || !g2.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
if g3.len() != 1 || !g3.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
let checksum_digit = (g3.as_bytes()[0] - b'0') as u32;
let sum: u32 = g1
.bytes()
.chain(g2.bytes())
.rev()
.enumerate()
.map(|(i, b)| (i as u32 + 1) * (b - b'0') as u32)
.sum();
sum % 10 == checksum_digit
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum AvailabilityStatus {
InStock,
LimitedStock,
BackOrdered,
MadeToOrder,
Discontinued,
}
pub(crate) fn parse_availability(s: &str) -> Option<AvailabilityStatus> {
match s.trim().to_ascii_lowercase().as_str() {
"in_stock" => Some(AvailabilityStatus::InStock),
"limited_stock" => Some(AvailabilityStatus::LimitedStock),
"back_ordered" => Some(AvailabilityStatus::BackOrdered),
"made_to_order" => Some(AvailabilityStatus::MadeToOrder),
"discontinued" => Some(AvailabilityStatus::Discontinued),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum CommercialSourceType {
UserSuppliedCsv,
VendorExport,
DistributorExport,
ManuallyTranscribed,
SyntheticFixture,
}
pub(crate) fn parse_source_type(s: &str) -> Option<CommercialSourceType> {
match s.trim().to_ascii_lowercase().as_str() {
"user_supplied_csv" => Some(CommercialSourceType::UserSuppliedCsv),
"vendor_export" => Some(CommercialSourceType::VendorExport),
"distributor_export" => Some(CommercialSourceType::DistributorExport),
"manually_transcribed" => Some(CommercialSourceType::ManuallyTranscribed),
"synthetic_fixture" => Some(CommercialSourceType::SyntheticFixture),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct OfferProvenance {
pub source_type: CommercialSourceType,
pub source_identifier: String,
pub retrieved_at: Option<String>,
pub supplied_by: Option<String>,
pub license_or_terms: Option<String>,
pub checksum: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CommercialPrecursorOffer {
pub offer_id: CommercialOfferId,
pub manufacturer: String,
pub product_name: String,
pub composition: Composition,
pub provenance: OfferProvenance,
pub formula: String,
pub catalog_number: Option<String>,
pub cas_number: Option<CasNumber>,
pub grade: Option<String>,
pub purity: Option<PurityFraction>,
pub package_mass: Option<PackageMass>,
pub unit_price: Option<Money>,
pub availability: Option<AvailabilityStatus>,
pub lead_time_days: Option<u32>,
pub physical_form: Option<String>,
pub particle_size_range_um: Option<ParticleSizeRangeUm>,
pub country_region: Option<String>,
pub product_url: Option<String>,
pub tags: BTreeSet<String>,
pub notes: Option<String>,
}
#[derive(Debug, Error, Clone, PartialEq)]
#[non_exhaustive]
pub enum CommercialCatalogError {
#[error("purity fraction must be > 0 and <= 1, got {value}")]
InvalidPurity { value: f64 },
#[error("package mass must be finite and > 0 grams, got {value}")]
InvalidPackageMass { value: f64 },
#[error("particle size range invalid: {reason}")]
InvalidParticleSizeRange { reason: String },
#[error("{code:?} is not a 3-letter uppercase ASCII currency code")]
InvalidCurrencyCode { code: String },
#[error("could not parse formula {formula:?}: {reason}")]
FormulaParseError { formula: String, reason: String },
#[error("inconsistent commercial planning request: {reason}")]
InconsistentRequest { reason: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommercialCatalogLoadMode {
Strict,
Lenient,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RejectedOffer {
pub row: usize,
pub offer_id: String,
pub field: String,
pub reason: String,
pub original_value: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CommercialCatalogLoadReport {
pub accepted: usize,
pub duplicate_offer_ids_collapsed: usize,
pub rejected: Vec<RejectedOffer>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CommercialPrecursorCatalog {
offers: Vec<CommercialPrecursorOffer>,
}
impl CommercialPrecursorCatalog {
pub fn from_offers(
mut offers: Vec<CommercialPrecursorOffer>,
) -> (Self, CommercialCatalogLoadReport) {
offers.sort_by(|a, b| a.offer_id.0.cmp(&b.offer_id.0));
let mut duplicate_offer_ids_collapsed = 0;
let mut deduped: Vec<CommercialPrecursorOffer> = Vec::with_capacity(offers.len());
for offer in offers {
if deduped
.last()
.is_some_and(|last: &CommercialPrecursorOffer| last.offer_id == offer.offer_id)
{
duplicate_offer_ids_collapsed += 1;
continue;
}
deduped.push(offer);
}
let report = CommercialCatalogLoadReport {
accepted: deduped.len(),
duplicate_offer_ids_collapsed,
rejected: Vec::new(),
};
(Self { offers: deduped }, report)
}
pub fn offers(&self) -> &[CommercialPrecursorOffer] {
&self.offers
}
pub fn get(&self, id: &CommercialOfferId) -> Option<&CommercialPrecursorOffer> {
self.offers.iter().find(|o| &o.offer_id == id)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MissingCommercialDataPolicy {
#[default]
Reject,
KeepWithWarning,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct CommercialPlanningRequest {
pub target_composition: Option<Composition>,
pub target_batch_mass_grams: Option<f64>,
pub allowed_manufacturers: Option<BTreeSet<String>>,
pub excluded_manufacturers: BTreeSet<String>,
pub min_purity: Option<PurityFraction>,
pub max_lead_time_days: Option<u32>,
pub allowed_availability_statuses: Option<BTreeSet<AvailabilityStatus>>,
pub allowed_physical_forms: Option<BTreeSet<String>>,
pub required_tags: BTreeSet<String>,
pub excluded_tags: BTreeSet<String>,
pub max_total_cost: Option<Money>,
pub allowed_currencies: Option<BTreeSet<CurrencyCode>>,
pub require_known_price: bool,
pub require_known_package_size: bool,
pub missing_data_policy: MissingCommercialDataPolicy,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CommercialPlanningConfig {
pub max_offers_per_precursor: usize,
pub max_combinations_evaluated: usize,
pub max_results_returned: usize,
}
impl Default for CommercialPlanningConfig {
fn default() -> Self {
Self {
max_offers_per_precursor: 50,
max_combinations_evaluated: 10_000,
max_results_returned: 5,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum CommercialExclusionCode {
PurityBelowMinimum,
ManufacturerNotAllowed,
LeadTimeExceedsMaximum,
MissingConstrainedField,
PriceRequiredButUnknown,
PackageSizeRequiredButUnknown,
AvailabilityExcluded,
RequiredTagMissing,
ExcludedTagPresent,
CurrencyNotAllowed,
PhysicalFormNotAllowed,
OfferCountCapExceeded,
EvaluationBudgetExhausted,
CostOverflow,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CommercialExclusion {
pub precursor: PrecursorId,
pub offer_id: Option<CommercialOfferId>,
pub reason_codes: Vec<CommercialExclusionCode>,
pub explanation: String,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CommercialWarning {
pub message: String,
pub severity: WarningSeverity,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct CommercialOfferSelection {
pub precursor: PrecursorId,
pub precursor_composition: Composition,
pub reaction_coefficient: u64,
pub offer_id: CommercialOfferId,
pub theoretical_pure_mass_required_grams: f64,
pub purity_adjusted_purchase_mass_grams: Option<f64>,
pub package_count: Option<u64>,
pub purchased_mass_grams: Option<f64>,
pub excess_mass_grams: Option<f64>,
pub subtotal: Option<Money>,
pub unresolved_fields: Vec<&'static str>,
pub assumptions: Vec<String>,
pub warnings: Vec<CommercialWarning>,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct CommercialCombination {
pub combination_id: String,
pub selections: Vec<CommercialOfferSelection>,
pub total_cost: Option<Money>,
pub all_costs_known: bool,
pub max_lead_time_days: Option<u32>,
pub all_availability_acceptable: bool,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SearchBudgetSummary {
pub combinations_evaluated: usize,
pub combinations_omitted: u64,
pub is_exhaustive: bool,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct UnresolvedCommercialField {
pub precursor: PrecursorId,
pub offer_id: CommercialOfferId,
pub field: &'static str,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct CommercialPlanAssessment {
pub plan_id: PlanId,
pub every_precursor_has_a_match: bool,
pub combinations: Vec<CommercialCombination>,
pub unmatched_precursors: Vec<(PrecursorId, Composition)>,
pub rejected_offers: Vec<CommercialExclusion>,
pub unresolved_commercial_fields: Vec<UnresolvedCommercialField>,
pub warnings: Vec<CommercialWarning>,
pub search_budget: SearchBudgetSummary,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commercial_catalog::test_support::*;
#[test]
fn purity_fraction_rejects_zero_and_above_one() {
assert!(PurityFraction::new(0.0).is_err());
assert!(PurityFraction::new(1.0000001).is_err());
assert!(PurityFraction::new(f64::NAN).is_err());
assert!(PurityFraction::new(1.0).is_ok());
assert!(PurityFraction::new(0.001).is_ok());
}
#[test]
fn package_mass_rejects_non_positive_and_converts_units() {
assert!(PackageMass::new(0.0).is_err());
assert!(PackageMass::new(-1.0).is_err());
assert_eq!(PackageMass::from_kilograms(1.0).unwrap().grams(), 1000.0);
assert_eq!(PackageMass::from_milligrams(500.0).unwrap().grams(), 0.5);
}
#[test]
fn currency_code_requires_three_uppercase_ascii_letters() {
assert!(CurrencyCode::new("USD").is_ok());
assert!(CurrencyCode::new("us").is_err());
assert!(CurrencyCode::new("usd").is_err());
assert!(CurrencyCode::new("USDD").is_err());
}
#[test]
fn money_checked_arithmetic_never_panics_on_overflow() {
let usd = CurrencyCode::new("USD").unwrap();
let eur = CurrencyCode::new("EUR").unwrap();
let big = Money::new(u64::MAX, usd);
assert_eq!(big.checked_mul_quantity(2), None);
assert_eq!(big.checked_add(&Money::new(1, usd)), None);
assert_eq!(
Money::new(1, usd).checked_add(&Money::new(1, eur)),
None,
"currencies never sum"
);
assert_eq!(
Money::new(100, usd).checked_add(&Money::new(50, usd)),
Some(Money::new(150, usd))
);
}
#[test]
fn money_checked_arithmetic_at_the_u64_max_boundary() {
let usd = CurrencyCode::new("USD").unwrap();
assert_eq!(
Money::new(u64::MAX, usd).checked_mul_quantity(1),
Some(Money::new(u64::MAX, usd)),
"multiplying by 1 at the exact max must succeed"
);
assert_eq!(
Money::new(u64::MAX - 1, usd).checked_add(&Money::new(1, usd)),
Some(Money::new(u64::MAX, usd)),
"summing to exactly the max must succeed"
);
assert_eq!(
Money::new(u64::MAX, usd).checked_add(&Money::new(1, usd)),
None,
"one past the max must overflow to None, not wrap"
);
let half_plus_one = u64::MAX / 2 + 1;
assert_eq!(
Money::new(half_plus_one, usd).checked_mul_quantity(2),
None,
"just over half the max, doubled, must overflow"
);
}
#[test]
fn cas_number_checksum_validates_water_and_rejects_a_bad_checksum() {
assert!(CasNumber::new("7732-18-5").checksum_verified); assert!(!CasNumber::new("7732-18-4").checksum_verified);
assert!(!CasNumber::new("not-a-cas-number").checksum_verified);
}
#[test]
fn from_offers_collapses_duplicate_ids_and_sorts_by_id() {
let (catalog, report) = CommercialPrecursorCatalog::from_offers(vec![
offer("B", "TiO2"),
offer("A", "Fe2O3"),
offer("A", "Fe2O3"),
]);
assert_eq!(report.accepted, 2);
assert_eq!(report.duplicate_offer_ids_collapsed, 1);
let ids: Vec<&str> = catalog
.offers()
.iter()
.map(|o| o.offer_id.0.as_str())
.collect();
assert_eq!(ids, vec!["A", "B"]);
}
#[test]
fn empty_catalog_is_not_an_error() {
let (catalog, report) = CommercialPrecursorCatalog::from_offers(vec![]);
assert!(catalog.offers().is_empty());
assert_eq!(report.accepted, 0);
}
}