datasynth-generators 2.4.0

50+ data generators covering GL, P2P, O2C, S2C, HR, manufacturing, audit, tax, treasury, and ESG
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
//! Sales quote generator.
//!
//! Generates realistic sales quotations with line items, following the
//! Quote-to-Cash lifecycle from draft through win/loss/expiry.

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;

/// Generates [`SalesQuote`] instances with realistic line items,
/// status distribution, and customer/material assignments.
pub struct SalesQuoteGenerator {
    rng: ChaCha8Rng,
    uuid_factory: DeterministicUuidFactory,
    /// Produces deterministic line-item IDs for QuoteLineItem.
    item_uuid_factory: DeterministicUuidFactory,
    /// Pool of real employee IDs for sales rep assignment.
    employee_ids_pool: Vec<String>,
    /// Pool of real customer IDs for customer assignment.
    customer_ids_pool: Vec<String>,
}

impl SalesQuoteGenerator {
    /// Create a new generator with the given seed.
    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(),
        }
    }

    /// Set ID pools for cross-reference coherence.
    ///
    /// When pools are non-empty, the generator selects `sales_rep_id` from
    /// `employee_ids` and `customer_id` from `customer_ids` instead of
    /// fabricating placeholder IDs.
    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
    }

    /// Generate sales quotes for the given period and configuration.
    ///
    /// Uses `"USD"` as the default currency. See [`generate_with_currency`](Self::generate_with_currency)
    /// for explicit currency control.
    ///
    /// # Arguments
    ///
    /// * `company_code` - The company code issuing quotes.
    /// * `customer_ids` - Slice of (customer_id, customer_name) tuples.
    /// * `material_ids` - Slice of (material_id, description) tuples.
    /// * `period_start` - Start of the generation period (inclusive).
    /// * `period_end` - End of the generation period (inclusive).
    /// * `config` - Sales quote configuration knobs.
    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",
        )
    }

    /// Generate sales quotes with a specific company currency.
    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();

        // Iterate over each month in the period
        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 {
            // Generate quotes_per_month quotes for this month
            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);
            }

            // Advance to next month
            if year == end_year && month == end_month {
                break;
            }
            month += 1;
            if month > 12 {
                month = 1;
                year += 1;
            }
        }

        quotes
    }

    /// Generate a single sales quote for a given month.
    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();

        // Random customer — prefer pool when available
        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];
            // Try to find matching name from customer_ids tuples
            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())
        };

        // Random quote date within the month
        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);

        // Generate 1-5 line items
        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,
            });
        }

        // Discount: 20% of quotes get 5-20% discount
        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)
        };

        // Status distribution:
        // win_rate fraction Won, 25% Lost, 15% Expired, 10% Sent, 5% Draft, rest Negotiating
        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
        };

        // Won quotes get a linked sales order ID
        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
        };

        // Lost quotes get a reason
        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
        };

        // Sales rep — prefer employee pool when available
        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,
        }
    }
}

/// Return the last day of the given month/year.
fn last_day_of_month(year: i32, month: u32) -> u32 {
    // The first day of the next month, minus one day
    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)
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[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,
        );

        // 3 months * 30 quotes_per_month = 90
        assert_eq!(quotes.len(), 90);

        // All quotes should have at least one line item
        for q in &quotes {
            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());
        }

        // Won quotes should have sales_order_id
        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"
            );
        }

        // Lost quotes should have a reason
        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;

        // Win rate should be roughly 35% (allow 20-50% range for randomness)
        let win_rate = won_count / total;
        assert!(
            win_rate > 0.20 && win_rate < 0.50,
            "Win rate {} should be roughly 35%",
            win_rate
        );

        // Lost rate should be roughly 25% (allow 15-35% range)
        let lost_rate = lost_count / total;
        assert!(
            lost_rate > 0.15 && lost_rate < 0.35,
            "Lost rate {} should be roughly 25%",
            lost_rate
        );
    }
}