use chrono::{Datelike, NaiveDate};
use datasynth_config::schema::SalesQuoteConfig;
use datasynth_core::models::{QuoteLineItem, QuoteStatus, SalesQuote};
use datasynth_core::utils::{sample_decimal_range, seeded_rng};
use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
use rand::prelude::*;
use rand_chacha::ChaCha8Rng;
use rust_decimal::Decimal;
pub struct SalesQuoteGenerator {
rng: ChaCha8Rng,
uuid_factory: DeterministicUuidFactory,
item_uuid_factory: DeterministicUuidFactory,
employee_ids_pool: Vec<String>,
customer_ids_pool: Vec<String>,
}
impl SalesQuoteGenerator {
pub fn new(seed: u64) -> Self {
Self {
rng: seeded_rng(seed, 0),
uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::SalesQuote),
item_uuid_factory: DeterministicUuidFactory::with_sub_discriminator(
seed,
GeneratorType::SalesQuote,
1,
),
employee_ids_pool: Vec::new(),
customer_ids_pool: Vec::new(),
}
}
pub fn with_pools(mut self, employee_ids: Vec<String>, customer_ids: Vec<String>) -> Self {
self.employee_ids_pool = employee_ids;
self.customer_ids_pool = customer_ids;
self
}
pub fn generate(
&mut self,
company_code: &str,
customer_ids: &[(String, String)],
material_ids: &[(String, String)],
period_start: NaiveDate,
period_end: NaiveDate,
config: &SalesQuoteConfig,
) -> Vec<SalesQuote> {
self.generate_with_currency(
company_code,
customer_ids,
material_ids,
period_start,
period_end,
config,
"USD",
)
}
pub fn generate_with_currency(
&mut self,
company_code: &str,
customer_ids: &[(String, String)],
material_ids: &[(String, String)],
period_start: NaiveDate,
period_end: NaiveDate,
config: &SalesQuoteConfig,
currency: &str,
) -> Vec<SalesQuote> {
if customer_ids.is_empty() || material_ids.is_empty() {
return Vec::new();
}
let mut quotes = Vec::new();
let mut year = period_start.year();
let mut month = period_start.month();
let end_year = period_end.year();
let end_month = period_end.month();
loop {
for _ in 0..config.quotes_per_month {
let quote = self.generate_single_quote(
company_code,
customer_ids,
material_ids,
year,
month,
config,
currency,
);
quotes.push(quote);
}
if year == end_year && month == end_month {
break;
}
month += 1;
if month > 12 {
month = 1;
year += 1;
}
}
quotes
}
fn generate_single_quote(
&mut self,
company_code: &str,
customer_ids: &[(String, String)],
material_ids: &[(String, String)],
year: i32,
month: u32,
config: &SalesQuoteConfig,
currency: &str,
) -> SalesQuote {
let quote_id = self.uuid_factory.next().to_string();
let (customer_id, customer_name) = if !self.customer_ids_pool.is_empty() {
let pool_idx = self.rng.random_range(0..self.customer_ids_pool.len());
let pool_id = &self.customer_ids_pool[pool_idx];
customer_ids
.iter()
.find(|(id, _)| id == pool_id)
.map(|(id, name)| (id.clone(), name.clone()))
.unwrap_or_else(|| (pool_id.clone(), pool_id.clone()))
} else {
let customer_idx = self.rng.random_range(0..customer_ids.len());
let (id, name) = &customer_ids[customer_idx];
(id.clone(), name.clone())
};
let last_day = last_day_of_month(year, month);
let day = self.rng.random_range(1..=last_day);
let quote_date = NaiveDate::from_ymd_opt(year, month, day).unwrap_or_else(|| {
NaiveDate::from_ymd_opt(year, month, 1)
.unwrap_or(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap_or_default())
});
let valid_until = quote_date + chrono::Duration::days(config.validity_days as i64);
let item_count = self.rng.random_range(1..=5usize);
let mut line_items = Vec::with_capacity(item_count);
let mut total_amount = Decimal::ZERO;
for item_num in 1..=item_count {
let mat_idx = self.rng.random_range(0..material_ids.len());
let (material_id, description) = &material_ids[mat_idx];
let unit_price =
sample_decimal_range(&mut self.rng, Decimal::from(50), Decimal::from(5000))
.round_dp(2);
let quantity =
sample_decimal_range(&mut self.rng, Decimal::ONE, Decimal::from(100)).round_dp(0);
let line_amount = (unit_price * quantity).round_dp(2);
total_amount += line_amount;
line_items.push(QuoteLineItem {
id: self.item_uuid_factory.next().to_string(),
item_number: item_num as u32,
material_id: material_id.clone(),
description: description.clone(),
quantity,
unit_price,
line_amount,
});
}
let (discount_percent, discount_amount) = if self.rng.random::<f64>() < 0.20 {
let pct = self.rng.random_range(0.05..0.20);
let disc_amount =
(Decimal::from_f64_retain(pct).unwrap_or(Decimal::ZERO) * total_amount).round_dp(2);
(pct, disc_amount)
} else {
(0.0, Decimal::ZERO)
};
let status_roll: f64 = self.rng.random();
let won_threshold = config.win_rate;
let lost_threshold = won_threshold + 0.25;
let expired_threshold = lost_threshold + 0.15;
let sent_threshold = expired_threshold + 0.10;
let draft_threshold = sent_threshold + 0.05;
let status = if status_roll < won_threshold {
QuoteStatus::Won
} else if status_roll < lost_threshold {
QuoteStatus::Lost
} else if status_roll < expired_threshold {
QuoteStatus::Expired
} else if status_roll < sent_threshold {
QuoteStatus::Sent
} else if status_roll < draft_threshold {
QuoteStatus::Draft
} else {
QuoteStatus::Negotiating
};
let sales_order_id = if status == QuoteStatus::Won {
let so_num = self.rng.random_range(1..=999999u32);
Some(format!("SO-{so_num:06}"))
} else {
None
};
let lost_reasons = [
"Price too high",
"Competitor won",
"Budget constraints",
"Requirements changed",
"Timing not right",
];
let lost_reason = if status == QuoteStatus::Lost {
let idx = self.rng.random_range(0..lost_reasons.len());
Some(lost_reasons[idx].to_string())
} else {
None
};
let sales_rep_id = if !self.employee_ids_pool.is_empty() {
let idx = self.rng.random_range(0..self.employee_ids_pool.len());
Some(self.employee_ids_pool[idx].clone())
} else {
let rep_num = self.rng.random_range(1..=20u32);
Some(format!("SR-{rep_num:02}"))
};
SalesQuote {
quote_id,
company_code: company_code.to_string(),
customer_id,
customer_name,
quote_date,
valid_until,
status,
line_items,
total_amount,
currency: currency.to_string(),
discount_percent,
discount_amount,
sales_rep_id,
sales_order_id,
lost_reason,
notes: None,
}
}
}
fn last_day_of_month(year: i32, month: u32) -> u32 {
let (next_year, next_month) = if month == 12 {
(year + 1, 1)
} else {
(year, month + 1)
};
NaiveDate::from_ymd_opt(next_year, next_month, 1)
.and_then(|d| d.pred_opt())
.map(|d| d.day())
.unwrap_or(28)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn sample_customers() -> Vec<(String, String)> {
vec![
("CUST-001".to_string(), "Acme Corp".to_string()),
("CUST-002".to_string(), "Globex Inc".to_string()),
("CUST-003".to_string(), "Initech LLC".to_string()),
]
}
fn sample_materials() -> Vec<(String, String)> {
vec![
("MAT-001".to_string(), "Widget A".to_string()),
("MAT-002".to_string(), "Widget B".to_string()),
("MAT-003".to_string(), "Gadget X".to_string()),
("MAT-004".to_string(), "Component Y".to_string()),
]
}
fn default_config() -> SalesQuoteConfig {
SalesQuoteConfig {
enabled: true,
quotes_per_month: 30,
win_rate: 0.35,
validity_days: 30,
}
}
#[test]
fn test_basic_generation_produces_expected_count() {
let mut gen = SalesQuoteGenerator::new(42);
let customers = sample_customers();
let materials = sample_materials();
let config = default_config();
let period_start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let period_end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
let quotes = gen.generate(
"C001",
&customers,
&materials,
period_start,
period_end,
&config,
);
assert_eq!(quotes.len(), 90);
for q in "es {
assert!(!q.line_items.is_empty());
assert!(q.line_items.len() <= 5);
assert!(q.total_amount > Decimal::ZERO);
assert!(!q.quote_id.is_empty());
assert_eq!(q.company_code, "C001");
assert!(q.sales_rep_id.is_some());
}
for q in quotes.iter().filter(|q| q.status == QuoteStatus::Won) {
assert!(
q.sales_order_id.is_some(),
"Won quotes must have a sales order ID"
);
}
for q in quotes.iter().filter(|q| q.status == QuoteStatus::Lost) {
assert!(
q.lost_reason.is_some(),
"Lost quotes must have a lost reason"
);
}
}
#[test]
fn test_deterministic_output_with_same_seed() {
let customers = sample_customers();
let materials = sample_materials();
let config = default_config();
let period_start = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
let period_end = NaiveDate::from_ymd_opt(2024, 6, 30).unwrap();
let mut gen1 = SalesQuoteGenerator::new(12345);
let quotes1 = gen1.generate(
"C001",
&customers,
&materials,
period_start,
period_end,
&config,
);
let mut gen2 = SalesQuoteGenerator::new(12345);
let quotes2 = gen2.generate(
"C001",
&customers,
&materials,
period_start,
period_end,
&config,
);
assert_eq!(quotes1.len(), quotes2.len());
for (q1, q2) in quotes1.iter().zip(quotes2.iter()) {
assert_eq!(q1.quote_id, q2.quote_id);
assert_eq!(q1.customer_id, q2.customer_id);
assert_eq!(q1.total_amount, q2.total_amount);
assert_eq!(q1.status, q2.status);
}
}
#[test]
fn test_status_distribution_within_range() {
let mut gen = SalesQuoteGenerator::new(999);
let customers = sample_customers();
let materials = sample_materials();
let config = SalesQuoteConfig {
enabled: true,
quotes_per_month: 100,
win_rate: 0.35,
validity_days: 30,
};
let period_start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let period_end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let quotes = gen.generate(
"C001",
&customers,
&materials,
period_start,
period_end,
&config,
);
let total = quotes.len() as f64;
let won_count = quotes
.iter()
.filter(|q| q.status == QuoteStatus::Won)
.count() as f64;
let lost_count = quotes
.iter()
.filter(|q| q.status == QuoteStatus::Lost)
.count() as f64;
let win_rate = won_count / total;
assert!(
win_rate > 0.20 && win_rate < 0.50,
"Win rate {} should be roughly 35%",
win_rate
);
let lost_rate = lost_count / total;
assert!(
lost_rate > 0.15 && lost_rate < 0.35,
"Lost rate {} should be roughly 25%",
lost_rate
);
}
}