1use chrono::NaiveDate;
4use datasynth_core::models::{
5 BomComponent, Material, MaterialAccountDetermination, MaterialGroup, MaterialPool,
6 MaterialType, UnitOfMeasure, ValuationMethod,
7};
8use datasynth_core::utils::seeded_rng;
9use rand::prelude::*;
10use rand_chacha::ChaCha8Rng;
11use rust_decimal::Decimal;
12use tracing::debug;
13
14#[derive(Debug, Clone)]
16pub struct MaterialGeneratorConfig {
17 pub material_type_distribution: Vec<(MaterialType, f64)>,
19 pub valuation_method_distribution: Vec<(ValuationMethod, f64)>,
21 pub bom_rate: f64,
23 pub default_uom: String,
25 pub gross_margin_range: (f64, f64),
27 pub standard_cost_range: (Decimal, Decimal),
29}
30
31impl Default for MaterialGeneratorConfig {
32 fn default() -> Self {
33 Self {
34 material_type_distribution: vec![
35 (MaterialType::FinishedGood, 0.30),
36 (MaterialType::RawMaterial, 0.35),
37 (MaterialType::SemiFinished, 0.15),
38 (MaterialType::TradingGood, 0.10),
39 (MaterialType::OperatingSupplies, 0.05),
40 (MaterialType::Packaging, 0.05),
41 ],
42 valuation_method_distribution: vec![
43 (ValuationMethod::StandardCost, 0.60),
44 (ValuationMethod::MovingAverage, 0.30),
45 (ValuationMethod::Fifo, 0.08),
46 (ValuationMethod::Lifo, 0.02),
47 ],
48 bom_rate: 0.25,
49 default_uom: "EA".to_string(),
50 gross_margin_range: (0.20, 0.50),
51 standard_cost_range: (Decimal::from(10), Decimal::from(10_000)),
52 }
53 }
54}
55
56const MATERIAL_DESCRIPTIONS: &[(MaterialType, &[&str])] = &[
58 (
59 MaterialType::FinishedGood,
60 &[
61 "Assembled Unit A",
62 "Complete Product B",
63 "Final Assembly C",
64 "Packaged Item D",
65 "Ready Product E",
66 "Finished Component F",
67 "Complete Module G",
68 "Final Product H",
69 ],
70 ),
71 (
72 MaterialType::RawMaterial,
73 &[
74 "Steel Plate Grade A",
75 "Aluminum Sheet 6061",
76 "Copper Wire AWG 12",
77 "Plastic Resin ABS",
78 "Raw Polymer Mix",
79 "Chemical Compound X",
80 "Base Material Y",
81 "Raw Stock Z",
82 ],
83 ),
84 (
85 MaterialType::SemiFinished,
86 &[
87 "Sub-Assembly Part A",
88 "Machined Component B",
89 "Intermediate Product C",
90 "Partial Assembly D",
91 "Semi-Complete Unit E",
92 "Work in Progress F",
93 "Partially Processed G",
94 "Intermediate Module H",
95 ],
96 ),
97 (
98 MaterialType::TradingGood,
99 &[
100 "Resale Item A",
101 "Trading Good B",
102 "Merchandise C",
103 "Distribution Item D",
104 "Wholesale Product E",
105 "Retail Item F",
106 "Trade Good G",
107 "Commercial Product H",
108 ],
109 ),
110 (
111 MaterialType::OperatingSupplies,
112 &[
113 "Cleaning Supplies",
114 "Office Supplies",
115 "Maintenance Supplies",
116 "Workshop Consumables",
117 "Safety Supplies",
118 "Facility Supplies",
119 "General Supplies",
120 "Operating Materials",
121 ],
122 ),
123 (
124 MaterialType::Packaging,
125 &[
126 "Cardboard Box Large",
127 "Plastic Container",
128 "Shipping Carton",
129 "Protective Wrap",
130 "Pallet Unit",
131 "Foam Insert",
132 "Label Roll",
133 "Tape Industrial",
134 ],
135 ),
136];
137
138pub struct MaterialGenerator {
140 rng: ChaCha8Rng,
141 seed: u64,
142 config: MaterialGeneratorConfig,
143 material_counter: usize,
144 created_materials: Vec<String>, country_pack: Option<datasynth_core::CountryPack>,
147}
148
149impl MaterialGenerator {
150 pub fn new(seed: u64) -> Self {
152 Self::with_config(seed, MaterialGeneratorConfig::default())
153 }
154
155 pub fn with_config(seed: u64, config: MaterialGeneratorConfig) -> Self {
157 Self {
158 rng: seeded_rng(seed, 0),
159 seed,
160 config,
161 material_counter: 0,
162 created_materials: Vec::new(),
163 country_pack: None,
164 }
165 }
166
167 pub fn set_country_pack(&mut self, pack: datasynth_core::CountryPack) {
169 self.country_pack = Some(pack);
170 }
171
172 pub fn generate_material(
174 &mut self,
175 _company_code: &str,
176 _effective_date: NaiveDate,
177 ) -> Material {
178 self.material_counter += 1;
179
180 let material_id = format!("MAT-{:06}", self.material_counter);
181 let material_type = self.select_material_type();
182 let description = self.select_description(&material_type);
183
184 let mut material =
185 Material::new(material_id.clone(), description.to_string(), material_type);
186
187 material.material_group = self.select_material_group(&material_type);
189
190 material.valuation_method = self.select_valuation_method();
192
193 let standard_cost = self.generate_standard_cost();
195 material.standard_cost = standard_cost;
196 material.purchase_price = standard_cost;
197 material.list_price = self.generate_list_price(standard_cost);
198
199 material.base_uom = if material_type == MaterialType::OperatingSupplies {
201 UnitOfMeasure::hour()
202 } else {
203 UnitOfMeasure::each()
204 };
205
206 material.account_determination = self.generate_account_determination(&material_type);
208
209 if material_type != MaterialType::OperatingSupplies {
211 material.safety_stock = self.generate_safety_stock();
212 material.reorder_point = material.safety_stock * Decimal::from(2);
213 }
214
215 self.created_materials.push(material_id);
217
218 material
219 }
220
221 pub fn generate_material_of_type(
223 &mut self,
224 material_type: MaterialType,
225 _company_code: &str,
226 _effective_date: NaiveDate,
227 ) -> Material {
228 self.material_counter += 1;
229
230 let material_id = format!("MAT-{:06}", self.material_counter);
231 let description = self.select_description(&material_type);
232
233 let mut material =
234 Material::new(material_id.clone(), description.to_string(), material_type);
235
236 material.material_group = self.select_material_group(&material_type);
237 material.valuation_method = self.select_valuation_method();
238
239 let standard_cost = self.generate_standard_cost();
240 material.standard_cost = standard_cost;
241 material.purchase_price = standard_cost;
242 material.list_price = self.generate_list_price(standard_cost);
243
244 material.base_uom = if material_type == MaterialType::OperatingSupplies {
245 UnitOfMeasure::hour()
246 } else {
247 UnitOfMeasure::each()
248 };
249
250 material.account_determination = self.generate_account_determination(&material_type);
251
252 if material_type != MaterialType::OperatingSupplies {
253 material.safety_stock = self.generate_safety_stock();
254 material.reorder_point = material.safety_stock * Decimal::from(2);
255 }
256
257 self.created_materials.push(material_id);
258
259 material
260 }
261
262 pub fn generate_material_with_bom(
264 &mut self,
265 company_code: &str,
266 effective_date: NaiveDate,
267 component_count: usize,
268 ) -> Material {
269 let mut components = Vec::new();
271 for i in 0..component_count {
272 let component_type = if i % 2 == 0 {
273 MaterialType::RawMaterial
274 } else {
275 MaterialType::SemiFinished
276 };
277 let component =
278 self.generate_material_of_type(component_type, company_code, effective_date);
279
280 let quantity = Decimal::from(self.rng.random_range(1..10));
281 components.push(BomComponent {
282 component_material_id: component.material_id.clone(),
283 quantity,
284 uom: component.base_uom.code.clone(),
285 position: (i + 1) as u16 * 10,
286 scrap_percentage: Decimal::ZERO,
287 is_optional: false,
288 });
289 }
290
291 let mut material = self.generate_material_of_type(
293 MaterialType::FinishedGood,
294 company_code,
295 effective_date,
296 );
297
298 material.bom_components = Some(components);
299
300 material
301 }
302
303 pub fn generate_material_pool(
305 &mut self,
306 count: usize,
307 company_code: &str,
308 effective_date: NaiveDate,
309 ) -> MaterialPool {
310 debug!(count, company_code, %effective_date, "Generating material pool");
311 let mut pool = MaterialPool::new();
312
313 for _ in 0..count {
314 let material = self.generate_material(company_code, effective_date);
315 pool.add_material(material);
316 }
317
318 pool
319 }
320
321 pub fn generate_material_pool_with_bom(
323 &mut self,
324 count: usize,
325 bom_rate: f64,
326 company_code: &str,
327 effective_date: NaiveDate,
328 ) -> MaterialPool {
329 let mut pool = MaterialPool::new();
330
331 let raw_count = (count as f64 * 0.4) as usize;
333 for _ in 0..raw_count {
334 let material = self.generate_material_of_type(
335 MaterialType::RawMaterial,
336 company_code,
337 effective_date,
338 );
339 pool.add_material(material);
340 }
341
342 let semi_count = (count as f64 * 0.2) as usize;
343 for _ in 0..semi_count {
344 let material = self.generate_material_of_type(
345 MaterialType::SemiFinished,
346 company_code,
347 effective_date,
348 );
349 pool.add_material(material);
350 }
351
352 let finished_count = count - raw_count - semi_count;
354 for _ in 0..finished_count {
355 let material =
356 if self.rng.random::<f64>() < bom_rate && !self.created_materials.is_empty() {
357 self.generate_material_with_bom_from_existing(company_code, effective_date)
358 } else {
359 self.generate_material_of_type(
360 MaterialType::FinishedGood,
361 company_code,
362 effective_date,
363 )
364 };
365 pool.add_material(material);
366 }
367
368 pool
369 }
370
371 fn generate_material_with_bom_from_existing(
373 &mut self,
374 company_code: &str,
375 effective_date: NaiveDate,
376 ) -> Material {
377 let mut material = self.generate_material_of_type(
378 MaterialType::FinishedGood,
379 company_code,
380 effective_date,
381 );
382
383 let component_count = self
385 .rng
386 .random_range(2..=5)
387 .min(self.created_materials.len());
388 let mut components = Vec::new();
389
390 for i in 0..component_count {
391 if let Some(component_material_id) = self.created_materials.get(i) {
392 components.push(BomComponent {
393 component_material_id: component_material_id.clone(),
394 quantity: Decimal::from(self.rng.random_range(1..5)),
395 uom: "EA".to_string(),
396 position: (i + 1) as u16 * 10,
397 scrap_percentage: Decimal::ZERO,
398 is_optional: false,
399 });
400 }
401 }
402
403 if !components.is_empty() {
404 material.bom_components = Some(components);
405 }
406
407 material
408 }
409
410 fn select_material_type(&mut self) -> MaterialType {
412 let roll: f64 = self.rng.random();
413 let mut cumulative = 0.0;
414
415 for (mat_type, prob) in &self.config.material_type_distribution {
416 cumulative += prob;
417 if roll < cumulative {
418 return *mat_type;
419 }
420 }
421
422 MaterialType::FinishedGood
423 }
424
425 fn select_valuation_method(&mut self) -> ValuationMethod {
427 let roll: f64 = self.rng.random();
428 let mut cumulative = 0.0;
429
430 for (method, prob) in &self.config.valuation_method_distribution {
431 cumulative += prob;
432 if roll < cumulative {
433 return *method;
434 }
435 }
436
437 ValuationMethod::StandardCost
438 }
439
440 fn select_description(&mut self, material_type: &MaterialType) -> &'static str {
442 for (mat_type, descriptions) in MATERIAL_DESCRIPTIONS {
443 if mat_type == material_type {
444 let idx = self.rng.random_range(0..descriptions.len());
445 return descriptions[idx];
446 }
447 }
448 "Generic Material"
449 }
450
451 fn select_material_group(&mut self, material_type: &MaterialType) -> MaterialGroup {
453 match material_type {
454 MaterialType::FinishedGood => {
455 let options = [
456 MaterialGroup::Electronics,
457 MaterialGroup::Mechanical,
458 MaterialGroup::FinishedGoods,
459 ];
460 options[self.rng.random_range(0..options.len())]
461 }
462 MaterialType::RawMaterial => {
463 let options = [
464 MaterialGroup::Chemicals,
465 MaterialGroup::Chemical,
466 MaterialGroup::Mechanical,
467 ];
468 options[self.rng.random_range(0..options.len())]
469 }
470 MaterialType::SemiFinished => {
471 let options = [MaterialGroup::Electronics, MaterialGroup::Mechanical];
472 options[self.rng.random_range(0..options.len())]
473 }
474 MaterialType::TradingGood => MaterialGroup::FinishedGoods,
475 MaterialType::OperatingSupplies => MaterialGroup::Services,
476 MaterialType::Packaging | MaterialType::SparePart => MaterialGroup::Consumables,
477 _ => MaterialGroup::Consumables,
478 }
479 }
480
481 fn generate_standard_cost(&mut self) -> Decimal {
483 let min = self.config.standard_cost_range.0;
484 let max = self.config.standard_cost_range.1;
485 let range = (max - min).to_string().parse::<f64>().unwrap_or(0.0);
486 let offset =
487 Decimal::from_f64_retain(self.rng.random::<f64>() * range).unwrap_or(Decimal::ZERO);
488 (min + offset).round_dp(2)
489 }
490
491 fn generate_list_price(&mut self, standard_cost: Decimal) -> Decimal {
493 let (min_margin, max_margin) = self.config.gross_margin_range;
494 let margin = min_margin + self.rng.random::<f64>() * (max_margin - min_margin);
495 let markup = Decimal::from_f64_retain(1.0 / (1.0 - margin)).unwrap_or(Decimal::from(2));
496 (standard_cost * markup).round_dp(2)
497 }
498
499 fn generate_safety_stock(&mut self) -> Decimal {
501 Decimal::from(self.rng.random_range(10..500))
502 }
503
504 fn generate_account_determination(
506 &mut self,
507 material_type: &MaterialType,
508 ) -> MaterialAccountDetermination {
509 match material_type {
510 MaterialType::FinishedGood | MaterialType::TradingGood => {
511 MaterialAccountDetermination {
512 inventory_account: "140000".to_string(),
513 cogs_account: "500000".to_string(),
514 revenue_account: "400000".to_string(),
515 purchase_expense_account: "500000".to_string(),
516 price_difference_account: "590000".to_string(),
517 gr_ir_account: "290000".to_string(),
518 }
519 }
520 MaterialType::RawMaterial | MaterialType::SemiFinished => {
521 MaterialAccountDetermination {
522 inventory_account: "141000".to_string(),
523 cogs_account: "510000".to_string(),
524 revenue_account: "400000".to_string(),
525 purchase_expense_account: "510000".to_string(),
526 price_difference_account: "591000".to_string(),
527 gr_ir_account: "290000".to_string(),
528 }
529 }
530 MaterialType::OperatingSupplies => MaterialAccountDetermination {
531 inventory_account: "".to_string(),
532 cogs_account: "520000".to_string(),
533 revenue_account: "410000".to_string(),
534 purchase_expense_account: "520000".to_string(),
535 price_difference_account: "".to_string(),
536 gr_ir_account: "290000".to_string(),
537 },
538 _ => MaterialAccountDetermination {
539 inventory_account: "145000".to_string(),
540 cogs_account: "530000".to_string(),
541 revenue_account: "400000".to_string(),
542 purchase_expense_account: "530000".to_string(),
543 price_difference_account: "595000".to_string(),
544 gr_ir_account: "290000".to_string(),
545 },
546 }
547 }
548
549 pub fn reset(&mut self) {
551 self.rng = seeded_rng(self.seed, 0);
552 self.material_counter = 0;
553 self.created_materials.clear();
554 }
555}
556
557#[cfg(test)]
558#[allow(clippy::unwrap_used)]
559mod tests {
560 use super::*;
561
562 #[test]
563 fn test_material_generation() {
564 let mut gen = MaterialGenerator::new(42);
565 let material = gen.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
566
567 assert!(!material.material_id.is_empty());
568 assert!(!material.description.is_empty());
569 assert!(material.standard_cost > Decimal::ZERO);
570 assert!(material.list_price >= material.standard_cost);
571 }
572
573 #[test]
574 fn test_material_pool_generation() {
575 let mut gen = MaterialGenerator::new(42);
576 let pool =
577 gen.generate_material_pool(50, "1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
578
579 assert_eq!(pool.materials.len(), 50);
580
581 let raw_count = pool
583 .materials
584 .iter()
585 .filter(|m| m.material_type == MaterialType::RawMaterial)
586 .count();
587 let finished_count = pool
588 .materials
589 .iter()
590 .filter(|m| m.material_type == MaterialType::FinishedGood)
591 .count();
592
593 assert!(raw_count > 0);
594 assert!(finished_count > 0);
595 }
596
597 #[test]
598 fn test_material_with_bom() {
599 let mut gen = MaterialGenerator::new(42);
600 let material =
601 gen.generate_material_with_bom("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), 3);
602
603 assert_eq!(material.material_type, MaterialType::FinishedGood);
604 assert!(material.bom_components.is_some());
605 assert_eq!(material.bom_components.as_ref().unwrap().len(), 3);
606 }
607
608 #[test]
609 fn test_material_pool_with_bom() {
610 let mut gen = MaterialGenerator::new(42);
611 let pool = gen.generate_material_pool_with_bom(
612 100,
613 0.5,
614 "1000",
615 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
616 );
617
618 assert_eq!(pool.materials.len(), 100);
619
620 let bom_count = pool
622 .materials
623 .iter()
624 .filter(|m| m.bom_components.is_some())
625 .count();
626
627 assert!(bom_count > 0);
628 }
629
630 #[test]
631 fn test_deterministic_generation() {
632 let mut gen1 = MaterialGenerator::new(42);
633 let mut gen2 = MaterialGenerator::new(42);
634
635 let material1 =
636 gen1.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
637 let material2 =
638 gen2.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
639
640 assert_eq!(material1.material_id, material2.material_id);
641 assert_eq!(material1.description, material2.description);
642 assert_eq!(material1.standard_cost, material2.standard_cost);
643 }
644
645 #[test]
646 fn test_material_margin() {
647 let mut gen = MaterialGenerator::new(42);
648
649 for _ in 0..10 {
650 let material =
651 gen.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
652
653 assert!(
655 material.list_price >= material.standard_cost,
656 "List price {} should be >= standard cost {}",
657 material.list_price,
658 material.standard_cost
659 );
660
661 let margin = material.gross_margin_percent();
663 assert!(
664 margin >= Decimal::from(15) && margin <= Decimal::from(55),
665 "Margin {} should be within expected range",
666 margin
667 );
668 }
669 }
670}